mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
Merge pull request #338 from Ark0N/feat/vertical-rail-detailed-rows
Vertical tab rail: detailed rows (created / working / status), plus a rename-cancel fix
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Vertical tab rail: detailed rows, and a rename cancel that no longer wipes the name.
|
||||||
|
|
||||||
|
The vertical rail (Tab Orientation → Vertical) now draws the same per-session
|
||||||
|
line the home screen and the rich sidebar draw — when the session was created,
|
||||||
|
how long it has been in the state it is in, the folder it runs in, and a status
|
||||||
|
pill — instead of just the name. New per-device setting **Vertical Rail Rows**
|
||||||
|
(`tabRailDetail`, App Settings → Appearance → Tabs) with `Detailed` as the
|
||||||
|
default and `Simple (name only)` as the opt-out. A rail that has never been
|
||||||
|
sized now opens at 320px (the existing Wide preset) so the line fits; a narrower
|
||||||
|
rail sheds the created stamp below 288px and falls back to simple rows below
|
||||||
|
240px.
|
||||||
|
|
||||||
|
Also fixes a data-loss bug in the inline tab rename that predates the rail:
|
||||||
|
pressing Escape cleared the input and blurred it, and the blur handler commits —
|
||||||
|
so cancelling a rename stored an EMPTY session name and the tab fell back to its
|
||||||
|
folder label. Escape now cancels without a request, in every layout.
|
||||||
+61
-13
@@ -3829,6 +3829,38 @@ class CodemanApp {
|
|||||||
return root.dataset.sessionList === 'sidebar' && root.dataset.sidebarDetail === 'rich';
|
return root.dataset.sessionList === 'sidebar' && root.dataset.sidebarDetail === 'rich';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the VERTICAL TAB RAIL (tabOrientation 'vertical') is showing the
|
||||||
|
* detailed rows: the same "created 3d ago · working 12m" line and status pill
|
||||||
|
* the rich sidebar and both home screens carry.
|
||||||
|
*
|
||||||
|
* A docked column is not a tab strip — that was the argument for the rich
|
||||||
|
* sidebar, and the rail is a docked column too, so it defaults to rich and
|
||||||
|
* `tabRailDetail: 'simple'` is the opt-out.
|
||||||
|
*
|
||||||
|
* The compact carve-out is not cosmetic: below 240px the rail already drops
|
||||||
|
* the row actions to a hover affordance, and three lines of stamps in a
|
||||||
|
* ~208px column ellipsize into noise. `_setTabRailWidth()` re-renders the
|
||||||
|
* tabs whenever that class flips, so this gate is re-read at the right moment.
|
||||||
|
*/
|
||||||
|
isTabRailRich() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
return (
|
||||||
|
root.getAttribute('data-tab-orientation') === 'vertical' &&
|
||||||
|
root.dataset.tabRailDetail === 'rich' &&
|
||||||
|
!root.classList.contains('tab-rail-compact')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one gate the render paths ask: does THIS list draw detailed rows?
|
||||||
|
* Either vertical surface can, and neither can be on at once (the sidebar
|
||||||
|
* owns the tabs whenever it is active, which forces the rail off).
|
||||||
|
*/
|
||||||
|
isRichTabRows() {
|
||||||
|
return this.isSessionSidebarRich() || this.isTabRailRich();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||||
* instead of a docked column.
|
* instead of a docked column.
|
||||||
@@ -4002,7 +4034,7 @@ class CodemanApp {
|
|||||||
this.showHomeSessions?.();
|
this.showHomeSessions?.();
|
||||||
}
|
}
|
||||||
// Only the rich rows carry stamps that go stale with no event behind them.
|
// Only the rich rows carry stamps that go stale with no event behind them.
|
||||||
if (this.isSessionSidebarRich()) this._startSidebarRichClock();
|
if (this.isRichTabRows()) this._startSidebarRichClock();
|
||||||
else this._stopSidebarRichClock();
|
else this._stopSidebarRichClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4119,13 +4151,14 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-row model for a rich sidebar row: which state the session is in,
|
* The per-row model for a rich row (detailed sidebar or vertical tab rail):
|
||||||
* when it was first created, and how long it has been in that state.
|
* which state the session is in, when it was first created, and how long it
|
||||||
|
* has been in that state.
|
||||||
*
|
*
|
||||||
* Classification is `_mobileOverviewState()` and the state duration is
|
* Classification is `_mobileOverviewState()` and the state duration is
|
||||||
* `_mobileOverviewSince()` (both mobile-overview.js), NOT re-derived here —
|
* `_mobileOverviewSince()` (both mobile-overview.js), NOT re-derived here —
|
||||||
* the sidebar, the desktop home rail and the phone overview must never
|
* the sidebar, the rail, the desktop home rail and the phone overview must
|
||||||
* disagree about what "working" means or about which stamp measures it.
|
* never disagree about what "working" means or about which stamp measures it.
|
||||||
*
|
*
|
||||||
* Guarded like every other cross-file consumer in this app: a stale cached
|
* Guarded like every other cross-file consumer in this app: a stale cached
|
||||||
* mobile-overview.js must degrade to a row with no meta line, not throw and
|
* mobile-overview.js must degrade to a row with no meta line, not throw and
|
||||||
@@ -4172,7 +4205,21 @@ class CodemanApp {
|
|||||||
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
|
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
|
||||||
}
|
}
|
||||||
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
|
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
|
||||||
return `<span class="tab-meta" data-i18n-skip>${parts.join('')}</span>`;
|
// Both absolute stamps ALSO on the line itself, not only on the two items.
|
||||||
|
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
|
||||||
|
// rule), and a tooltip on a `display: none` element has no hover target —
|
||||||
|
// so without this the created stamp is not merely shrunk, it is gone with
|
||||||
|
// no way to ask for it. The pill and the gaps around the stamps are the
|
||||||
|
// hover targets that remain; an item's own title still wins over this one
|
||||||
|
// where the item is visible.
|
||||||
|
const lineTitle = [
|
||||||
|
row.createdAt ? `First created: ${new Date(row.createdAt).toLocaleString()}` : '',
|
||||||
|
row.since && row.since.at ? `${row.since.key}: ${new Date(row.since.at).toLocaleString()}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' \u00B7 ');
|
||||||
|
const lineTitleAttr = lineTitle ? ` title="${escapeHtml(lineTitle)}"` : '';
|
||||||
|
return `<span class="tab-meta"${lineTitleAttr} data-i18n-skip>${parts.join('')}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same formatter as both home screens, so a duration is written the same way everywhere. */
|
/** Same formatter as both home screens, so a duration is written the same way everywhere. */
|
||||||
@@ -4217,7 +4264,7 @@ class CodemanApp {
|
|||||||
_startSidebarRichClock() {
|
_startSidebarRichClock() {
|
||||||
if (this._sidebarRichClock) return;
|
if (this._sidebarRichClock) return;
|
||||||
this._sidebarRichClock = setInterval(() => {
|
this._sidebarRichClock = setInterval(() => {
|
||||||
if (!this.isSessionSidebarRich()) {
|
if (!this.isRichTabRows()) {
|
||||||
this._stopSidebarRichClock();
|
this._stopSidebarRichClock();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4434,7 +4481,7 @@ class CodemanApp {
|
|||||||
if (canIncremental) {
|
if (canIncremental) {
|
||||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||||
// the DOM and the loop below runs for every session on every SSE tick.
|
// the DOM and the loop below runs for every session on every SSE tick.
|
||||||
const richRows = this.isSessionSidebarRich();
|
const richRows = this.isRichTabRows();
|
||||||
// Incremental update - only modify changed properties
|
// Incremental update - only modify changed properties
|
||||||
for (const [id, session] of this.sessions) {
|
for (const [id, session] of this.sessions) {
|
||||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||||
@@ -4743,9 +4790,9 @@ class CodemanApp {
|
|||||||
// into view replaces it.
|
// into view replaces it.
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const tabOrder = this.sessionOrder;
|
const tabOrder = this.sessionOrder;
|
||||||
// Read once, not per session: isSessionSidebarRich() touches the DOM and
|
// Read once, not per session: isRichTabRows() touches the DOM and
|
||||||
// this loop runs for every tab on every full rebuild.
|
// this loop runs for every tab on every full rebuild.
|
||||||
const richRows = this.isSessionSidebarRich();
|
const richRows = this.isRichTabRows();
|
||||||
let _tabIdx = 0;
|
let _tabIdx = 0;
|
||||||
for (const id of tabOrder) {
|
for (const id of tabOrder) {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
@@ -4789,9 +4836,10 @@ class CodemanApp {
|
|||||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||||
: (session.workingDir || '');
|
: (session.workingDir || '');
|
||||||
|
|
||||||
// Rich sidebar rows only: the home screen's created/state stamps and a
|
// Rich rows only (the detailed sidebar OR the vertical tab rail): the home
|
||||||
// status pill. richRow is null in every other layout, and both helpers
|
// screen's created/state stamps and a status pill. richRow is null in every
|
||||||
// below collapse to '' — the header strip's markup is unchanged.
|
// other layout, and both helpers below collapse to '' — the header strip's
|
||||||
|
// markup is unchanged.
|
||||||
const richRow = richRows ? this._sidebarRichRow(id, session) : null;
|
const richRow = richRows ? this._sidebarRichRow(id, session) : null;
|
||||||
const richMeta = this._sidebarRichMetaHTML(richRow);
|
const richMeta = this._sidebarRichMetaHTML(richRow);
|
||||||
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
|
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ function resolveTabOrientation(input) {
|
|||||||
|
|
||||||
const TAB_RAIL_MIN_WIDTH = 208;
|
const TAB_RAIL_MIN_WIDTH = 208;
|
||||||
const TAB_RAIL_DEFAULT_WIDTH = 256;
|
const TAB_RAIL_DEFAULT_WIDTH = 256;
|
||||||
|
/** Detailed rows carry a third line, and it ellipsizes at 256px — see the
|
||||||
|
rich sidebar's own 300px column. 320px is the existing Wide preset. */
|
||||||
|
const TAB_RAIL_RICH_DEFAULT_WIDTH = 320;
|
||||||
const TAB_RAIL_MAX_WIDTH = 360;
|
const TAB_RAIL_MAX_WIDTH = 360;
|
||||||
|
|
||||||
function resolveTabRailWidth(input = {}) {
|
function resolveTabRailWidth(input = {}) {
|
||||||
@@ -185,7 +188,9 @@ function resolveTabRailKeyboardWidth(input = {}) {
|
|||||||
let width;
|
let width;
|
||||||
if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH;
|
if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH;
|
||||||
else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH;
|
else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH;
|
||||||
else if (input.key === 'Enter') width = TAB_RAIL_DEFAULT_WIDTH;
|
// Enter resets to the caller's effective default (the rich rail's is the
|
||||||
|
// Wide preset, not 256 — see _defaultTabRailWidth); absent, the base default.
|
||||||
|
else if (input.key === 'Enter') width = Number(input.defaultWidth) || TAB_RAIL_DEFAULT_WIDTH;
|
||||||
else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') {
|
else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') {
|
||||||
const direction = input.key === 'ArrowLeft' ? -1 : 1;
|
const direction = input.key === 'ArrowLeft' ? -1 : 1;
|
||||||
width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8);
|
width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8);
|
||||||
@@ -686,6 +691,7 @@ if (typeof window !== 'undefined') {
|
|||||||
};
|
};
|
||||||
window.CodemanTabRail = {
|
window.CodemanTabRail = {
|
||||||
DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH,
|
DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH,
|
||||||
|
RICH_DEFAULT_WIDTH: TAB_RAIL_RICH_DEFAULT_WIDTH,
|
||||||
MIN_WIDTH: TAB_RAIL_MIN_WIDTH,
|
MIN_WIDTH: TAB_RAIL_MIN_WIDTH,
|
||||||
MAX_WIDTH: TAB_RAIL_MAX_WIDTH,
|
MAX_WIDTH: TAB_RAIL_MAX_WIDTH,
|
||||||
resolveWidth: resolveTabRailWidth,
|
resolveWidth: resolveTabRailWidth,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||||
here and boot will contradict this value, animating the drawer open by
|
here and boot will contradict this value, animating the drawer open by
|
||||||
itself on every load between 768 and 1023px. -->
|
itself on every load between 768 and 1023px. -->
|
||||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';var W=Number(A.tabRailWidth);if(V&&Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';}</script>
|
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';}</script>
|
||||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||||
<style>
|
<style>
|
||||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||||
@@ -1925,6 +1925,16 @@
|
|||||||
<option value="vertical">Vertical (side rail)</option>
|
<option value="vertical">Vertical (side rail)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Vertical Rail Rows</span>
|
||||||
|
<span class="set-row-desc">Detailed rows carry the home screen's per-session line (created, how long it has been working or idle) and a status pill. Needs ~280px of rail; a rail narrower than 240px drops back to simple rows.</span>
|
||||||
|
</div>
|
||||||
|
<select id="appSettingsTabRailDetail" class="set-select">
|
||||||
|
<option value="rich">Detailed</option>
|
||||||
|
<option value="simple">Simple (name only)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
|
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Vertical Rail Width</span>
|
<span class="set-row-label">Vertical Rail Width</span>
|
||||||
|
|||||||
@@ -2196,8 +2196,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
input.blur();
|
input.blur();
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
input.value = '';
|
// Cancel, never commit. This used to clear the field and blur, and the
|
||||||
input.blur();
|
// blur handler commits — so Escape RENAMED the session to an empty
|
||||||
|
// string (measured: the tab fell back to its folder name and the server
|
||||||
|
// stored ""), in every layout. cancelRename() marks the edit
|
||||||
|
// invalidated, so the blur that follows the input's removal is a no-op.
|
||||||
|
e.preventDefault();
|
||||||
|
cancelRename();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -416,9 +416,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsTabOrientation').value =
|
document.getElementById('appSettingsTabOrientation').value =
|
||||||
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
||||||
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
||||||
width: settings.tabRailWidth ?? defaults.tabRailWidth ?? 256,
|
// Same default resolution as applyTabRailWidth(): a rail that has never
|
||||||
|
// been sized shows the width it is actually rendering at, which for
|
||||||
|
// detailed rows is the Wide preset rather than 256. The rich-aware
|
||||||
|
// default must come BEFORE the per-device defaults blob: the handheld
|
||||||
|
// blob carries tabRailWidth: 256, which applyTabRailWidth() never reads,
|
||||||
|
// so consulting it first showed a tablet's unsized rich rail as 256 while
|
||||||
|
// it rendered at 320 — and a routine Save then PERSISTED the 256.
|
||||||
|
width: settings.tabRailWidth ?? this._defaultTabRailWidth?.() ?? defaults.tabRailWidth ?? 256,
|
||||||
}) ?? 256;
|
}) ?? 256;
|
||||||
this.syncTabRailWidthSetting?.(tabRailWidth);
|
this.syncTabRailWidthSetting?.(tabRailWidth);
|
||||||
|
document.getElementById('appSettingsTabRailDetail').value =
|
||||||
|
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||||
document.getElementById('appSettingsSessionListLayout').value =
|
document.getElementById('appSettingsSessionListLayout').value =
|
||||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||||
@@ -2055,6 +2064,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||||
|
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||||
@@ -2457,6 +2467,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
tabTwoRows: false,
|
tabTwoRows: false,
|
||||||
tabOrientation: 'horizontal',
|
tabOrientation: 'horizontal',
|
||||||
tabRailWidth: 256,
|
tabRailWidth: 256,
|
||||||
|
tabRailDetail: 'rich',
|
||||||
sessionListLayout: 'header',
|
sessionListLayout: 'header',
|
||||||
sessionSidebarFontSize: 12,
|
sessionSidebarFontSize: 12,
|
||||||
cjkInputEnabled: false,
|
cjkInputEnabled: false,
|
||||||
@@ -2715,6 +2726,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
|
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
|
||||||
root.setAttribute('data-tab-orientation', orientation);
|
root.setAttribute('data-tab-orientation', orientation);
|
||||||
|
|
||||||
|
// Row detail rides on its OWN attribute, exactly like the sidebar's
|
||||||
|
// data-sidebar-detail: every html[data-tab-orientation='vertical'] rule in
|
||||||
|
// styles.css keeps matching both variants untouched, and the gate in app.js
|
||||||
|
// reads one attribute instead of re-parsing localStorage per tab.
|
||||||
|
const previousDetail = root.dataset.tabRailDetail || 'rich';
|
||||||
|
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
|
||||||
|
root.dataset.tabRailDetail = detail;
|
||||||
|
|
||||||
const tabsEl = document.getElementById('sessionTabs');
|
const tabsEl = document.getElementById('sessionTabs');
|
||||||
const rail = document.getElementById('tabRail');
|
const rail = document.getElementById('tabRail');
|
||||||
const headerHost = document.getElementById('sessionTabsHost');
|
const headerHost = document.getElementById('sessionTabsHost');
|
||||||
@@ -2732,13 +2751,43 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const settleRailWidth =
|
const settleRailWidth =
|
||||||
options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation);
|
options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation);
|
||||||
this.applyTabRailWidth?.({ settle: settleRailWidth });
|
this.applyTabRailWidth?.({ settle: settleRailWidth });
|
||||||
if (previous !== orientation) {
|
const orientationChanged = previous !== orientation;
|
||||||
|
// A detail flip counts as a change on its own: simple ⟷ detailed leaves the
|
||||||
|
// orientation on 'vertical' both times, and the stamps line is emitted by
|
||||||
|
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||||
|
// detail half in applySessionListLayout(). Taller rows also move every
|
||||||
|
// connector anchored to a tab rect.
|
||||||
|
const changed = orientationChanged || previousDetail !== detail;
|
||||||
|
if (orientationChanged) {
|
||||||
this.updateTabOverflowMode?.();
|
this.updateTabOverflowMode?.();
|
||||||
if (!settleRailWidth) this.fitAddon?.fit();
|
if (!settleRailWidth) this.fitAddon?.fit();
|
||||||
this._fullRenderSessionTabs?.();
|
}
|
||||||
|
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
|
||||||
|
// rail-aware, so it has to run AFTER the two attributes above — the
|
||||||
|
// applySessionListLayout() call that precedes this one on the settings-save
|
||||||
|
// path ran while data-tab-rail-detail still held the old value. It
|
||||||
|
// re-renders by itself when the folder row appears or disappears, which is
|
||||||
|
// why the render below is skipped in that case rather than doubled.
|
||||||
|
const prevTall = this._tallTabsEnabled;
|
||||||
|
if (changed) this.applyTabWrapSettings?.();
|
||||||
|
if (changed) {
|
||||||
|
// Mirror of applyTabWrapSettings()'s OWN render condition, which is
|
||||||
|
// `prevTallTabs !== undefined && prevTallTabs !== showFolder`: its first
|
||||||
|
// call ever only establishes the baseline and deliberately renders
|
||||||
|
// nothing. Reading an undefined previous value as "it rendered" skips
|
||||||
|
// BOTH renders and leaves the rows stale — reachable whenever this is the
|
||||||
|
// first call, i.e. when the pre-paint script threw and left the
|
||||||
|
// attributes on their fallbacks for applyTabOrientation() to correct.
|
||||||
|
const wrapRendered = prevTall !== undefined && prevTall !== this._tallTabsEnabled;
|
||||||
|
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||||
this._updateConnectionLinesImmediate?.();
|
this._updateConnectionLinesImmediate?.();
|
||||||
this._refreshHomeSessionsIfVisible?.();
|
this._refreshHomeSessionsIfVisible?.();
|
||||||
}
|
}
|
||||||
|
// Only detailed rows carry stamps that go stale with no event behind them.
|
||||||
|
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
|
||||||
|
// on paths where nothing re-rendered (boot with the layout already applied).
|
||||||
|
if (this.isRichTabRows?.()) this._startSidebarRichClock?.();
|
||||||
|
else this._stopSidebarRichClock?.();
|
||||||
},
|
},
|
||||||
|
|
||||||
applyTabWrapSettings() {
|
applyTabWrapSettings() {
|
||||||
@@ -2760,7 +2809,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const twoRows = !sidebar && deviceType === 'desktop'
|
const twoRows = !sidebar && deviceType === 'desktop'
|
||||||
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
|
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
|
||||||
: false;
|
: false;
|
||||||
const showFolder = sidebar || twoRows;
|
// The DETAILED vertical rail is the third tall-row surface, for the same
|
||||||
|
// reason as the sidebar: it is a docked column with a row per session, and
|
||||||
|
// the stamps line below the name says nothing about WHICH project the
|
||||||
|
// session is in. Read from the applied attribute, which applyTabOrientation()
|
||||||
|
// has already written (app.js calls it before this).
|
||||||
|
const railRich = this.isTabRailRich?.() === true;
|
||||||
|
const showFolder = sidebar || twoRows || railRich;
|
||||||
const prevTallTabs = this._tallTabsEnabled;
|
const prevTallTabs = this._tallTabsEnabled;
|
||||||
this._tallTabsEnabled = showFolder;
|
this._tallTabsEnabled = showFolder;
|
||||||
const tabsEl = document.getElementById('sessionTabs');
|
const tabsEl = document.getElementById('sessionTabs');
|
||||||
@@ -2974,7 +3029,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||||
'terminalFontFamily',
|
'terminalFontFamily',
|
||||||
'language',
|
'language',
|
||||||
|
|||||||
+61
-17
@@ -17275,19 +17275,31 @@ html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Rich rows (sessionListLayout 'sidebar-rich') ----------------------- */
|
/* --- Rich rows (sessionListLayout 'sidebar-rich' + tabRailDetail 'rich') --- */
|
||||||
/* The detailed variant of the SAME sidebar: identical column, identical
|
/* The detailed variant of the SAME sidebar: identical column, identical
|
||||||
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
|
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
|
||||||
difference is that each row also carries the line the desktop home rail and
|
difference is that each row also carries the line the desktop home rail and
|
||||||
the phone overview carry — when the session was first created, how long it
|
the phone overview carry — when the session was first created, how long it
|
||||||
has been in the state it is in, and a status pill.
|
has been in the state it is in, and a status pill.
|
||||||
|
|
||||||
Everything here is scoped to html[data-sidebar-detail="rich"], which
|
The VERTICAL TAB RAIL is the second surface that draws those rows (it is a
|
||||||
|
docked column too, and #sessionTabs is the same element re-parented into it),
|
||||||
|
so every rule below carries a rail twin as an extra COMMA-GROUPED selector.
|
||||||
|
Deliberately not :is(): an :is() list takes its most specific argument's
|
||||||
|
specificity, which would silently raise the sidebar arm from (0,3,1) to the
|
||||||
|
rail arm's (0,5,1) and let these paint rules outrank things they never used
|
||||||
|
to. Grouped selectors each keep their own weight.
|
||||||
|
|
||||||
|
Sidebar rules are scoped to html[data-sidebar-detail="rich"], which
|
||||||
applySessionListLayout() only ever sets to 'rich' while data-session-list is
|
applySessionListLayout() only ever sets to 'rich' while data-session-list is
|
||||||
'sidebar'. `.tab-meta` is emitted by the row template exclusively in that
|
'sidebar'; rail rules to html[data-tab-orientation='vertical']
|
||||||
mode, so these rules have nothing to match anywhere else — the display:none
|
[data-tab-rail-detail='rich']:not(.tab-rail-compact), so a rail dragged below
|
||||||
below is the second lock, not the mechanism. */
|
240px drops back to simple rows the same way the collapsed sidebar does.
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta {
|
`.tab-meta` is emitted by the row template exclusively in those modes, so
|
||||||
|
these rules have nothing to match anywhere else — the display:none below is
|
||||||
|
the second lock, not the mechanism. */
|
||||||
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35em;
|
gap: 0.35em;
|
||||||
@@ -17309,33 +17321,56 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-meta {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item {
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-key {
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-key,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-key {
|
||||||
margin-right: 0.35em;
|
margin-right: 0.35em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-sep {
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-sep,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-sep {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Narrower than the detailed default (the .tab-rail-tight class,
|
||||||
|
_setTabRailWidth): the created stamp is dropped rather than shown as
|
||||||
|
"CREA…". Its value stays reachable as the tooltip on the meta LINE
|
||||||
|
(`.tab-meta` carries both absolute stamps, _sidebarRichMetaHTML) — the title
|
||||||
|
on the hidden `.tab-meta-created` itself goes away with it, since a
|
||||||
|
`display: none` element has no hover target. */
|
||||||
|
html.tab-rail-tight[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-created,
|
||||||
|
html.tab-rail-tight[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a rail narrower than the detailed default, the state duration is the last
|
||||||
|
thing that should go: it is the number the row is sorted by, and the pill
|
||||||
|
next to it is only a word. The created stamp ellipsizes instead. */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-since {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* While a session is actually doing something, how long it has been doing it is
|
/* While a session is actually doing something, how long it has been doing it is
|
||||||
what the eye should land on — same emphasis the home rail gives it. */
|
what the eye should land on — same emphasis the home rail gives it. */
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .session-tab.tab-state-working .tab-meta-since {
|
html[data-sidebar-detail="rich"] .session-sidebar .session-tab.tab-state-working .tab-meta-since,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-working .tab-meta-since {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pushed hard right and never shrinking, so the stamps ellipsize before the
|
/* Pushed hard right and never shrinking, so the stamps ellipsize before the
|
||||||
status word does. */
|
status word does. */
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill {
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 0.1em 0.5em;
|
padding: 0.1em 0.5em;
|
||||||
@@ -17352,19 +17387,23 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill {
|
|||||||
/* Same three colors as every other session surface: red means a question is
|
/* Same three colors as every other session surface: red means a question is
|
||||||
pending, yellow means it wants input, green means work is happening. */
|
pending, yellow means it wants input, green means work is happening. */
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--needs,
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--needs,
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--error {
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--error,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--needs,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--error {
|
||||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--red) 45%, transparent);
|
border-color: color-mix(in srgb, var(--red) 45%, transparent);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--waiting {
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--waiting,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--waiting {
|
||||||
background: color-mix(in srgb, var(--yellow) 18%, transparent);
|
background: color-mix(in srgb, var(--yellow) 18%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--yellow) 45%, transparent);
|
border-color: color-mix(in srgb, var(--yellow) 45%, transparent);
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working {
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--working {
|
||||||
background: color-mix(in srgb, var(--green) 15%, transparent);
|
background: color-mix(in srgb, var(--green) 15%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--green) 40%, transparent);
|
border-color: color-mix(in srgb, var(--green) 40%, transparent);
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
@@ -17372,7 +17411,8 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working {
|
|||||||
|
|
||||||
/* Muted one step further than the idle dot: the pill is a block of color, so it
|
/* Muted one step further than the idle dot: the pill is a block of color, so it
|
||||||
reads louder than a 9px dot at the same mix. */
|
reads louder than a 9px dot at the same mix. */
|
||||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle {
|
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--idle {
|
||||||
background: color-mix(in srgb, var(--green) 7%, transparent);
|
background: color-mix(in srgb, var(--green) 7%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--green) 18%, var(--border));
|
border-color: color-mix(in srgb, var(--green) 18%, var(--border));
|
||||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||||
@@ -17387,7 +17427,8 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle {
|
|||||||
then just a status dot and its badges. Without the guard, `align-items:
|
then just a status dot and its badges. Without the guard, `align-items:
|
||||||
flex-start` and a 0.15rem top margin on .tab-status would push that dot off
|
flex-start` and a 0.15rem top margin on .tab-status would push that dot off
|
||||||
the centre line of every row in the rail. */
|
the centre line of every row in the rail. */
|
||||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab {
|
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 0.45rem 0.5rem;
|
padding: 0.45rem 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -17396,7 +17437,10 @@ html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sideba
|
|||||||
three-line one it drifts low, so pin it to the name it acts on. */
|
three-line one it drifts low, so pin it to the name it acts on. */
|
||||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-actions,
|
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-actions,
|
||||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-number,
|
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-number,
|
||||||
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-status {
|
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-status,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name-row > .tab-actions,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-number,
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-status {
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,27 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const wasCompact = root.classList.contains('tab-rail-compact');
|
const wasCompact = root.classList.contains('tab-rail-compact');
|
||||||
const compact = resolved < 240;
|
const compact = resolved < 240;
|
||||||
root.classList.toggle('tab-rail-compact', compact);
|
root.classList.toggle('tab-rail-compact', compact);
|
||||||
if (wasCompact !== compact) this._fullRenderSessionTabs?.();
|
// Second, softer threshold, CSS-only: a detailed row carries two stamps and
|
||||||
|
// below ~288px the created one ellipsizes to "CREA…", which says nothing.
|
||||||
|
// It is dropped there instead, leaving the state duration (the number the
|
||||||
|
// list is ordered by) and its pill intact. No re-render — unlike the rows
|
||||||
|
// themselves, this is a display toggle on markup that is already there.
|
||||||
|
root.classList.toggle('tab-rail-tight', resolved < 288);
|
||||||
|
if (wasCompact !== compact) {
|
||||||
|
// The folder line is owned by applyTabWrapSettings(), whose railRich
|
||||||
|
// input reads the compact class this function just toggled — without
|
||||||
|
// re-running it, a rich rail dragged below 240px kept emitting folder
|
||||||
|
// rows (and, for a stored width < 240, kept them across reloads: the
|
||||||
|
// boot-time wrap pass runs before this function first applies the
|
||||||
|
// class). It re-renders only when the folder flag actually flipped, so
|
||||||
|
// cover the flip-without-folder-change case (a simple-detail rail
|
||||||
|
// crossing 240px still changes the row-action affordance) without
|
||||||
|
// rendering twice.
|
||||||
|
const prevTall = this._tallTabsEnabled;
|
||||||
|
this.applyTabWrapSettings?.();
|
||||||
|
const wrapRendered = prevTall !== undefined && this._tallTabsEnabled !== prevTall;
|
||||||
|
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||||
|
}
|
||||||
const handle = document.getElementById('tabRailResizeHandle');
|
const handle = document.getElementById('tabRailResizeHandle');
|
||||||
if (handle) {
|
if (handle) {
|
||||||
handle.setAttribute('aria-valuemax', String(effectiveMax));
|
handle.setAttribute('aria-valuemax', String(effectiveMax));
|
||||||
@@ -144,9 +164,28 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}, 150);
|
}, 150);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The width a rail gets when the user has never picked one.
|
||||||
|
*
|
||||||
|
* Detailed rows carry a third line ("created 3d ago · working 12m" plus a
|
||||||
|
* status pill) and at 256px that line ellipsizes before it is finished — the
|
||||||
|
* same reason the rich SIDEBAR is 300px and the simple one 260px. 320px is
|
||||||
|
* the existing Wide preset, so a fresh detailed rail lands on a named choice
|
||||||
|
* rather than reading "Custom" in the settings select.
|
||||||
|
*
|
||||||
|
* Only the DEFAULT moves: a width the user has actually chosen (stored) is
|
||||||
|
* never overridden, and dragging the rail narrower is never fought — below
|
||||||
|
* 240px the rows drop back to simple ones on their own.
|
||||||
|
*/
|
||||||
|
_defaultTabRailWidth() {
|
||||||
|
const rich = document.documentElement.dataset.tabRailDetail !== 'simple';
|
||||||
|
if (rich) return window.CodemanTabRail?.RICH_DEFAULT_WIDTH ?? 320;
|
||||||
|
return window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
|
||||||
|
},
|
||||||
|
|
||||||
applyTabRailWidth(options = {}) {
|
applyTabRailWidth(options = {}) {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
const requested = settings.tabRailWidth ?? window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
|
const requested = settings.tabRailWidth ?? this._defaultTabRailWidth();
|
||||||
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
|
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
|
||||||
if (options.settle) this._claimTabRailResize();
|
if (options.settle) this._claimTabRailResize();
|
||||||
const resolved = this._setTabRailWidth(preferred);
|
const resolved = this._setTabRailWidth(preferred);
|
||||||
@@ -198,6 +237,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
key: event.key,
|
key: event.key,
|
||||||
shiftKey: event.shiftKey,
|
shiftKey: event.shiftKey,
|
||||||
currentWidth: this._getCurrentTabRailWidth(),
|
currentWidth: this._getCurrentTabRailWidth(),
|
||||||
|
defaultWidth: this._defaultTabRailWidth?.(),
|
||||||
...this._getTabRailBounds(),
|
...this._getTabRailBounds(),
|
||||||
});
|
});
|
||||||
if (width === null || width === undefined) return;
|
if (width === null || width === undefined) return;
|
||||||
@@ -240,7 +280,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
handle.addEventListener('dblclick', (event) => {
|
handle.addEventListener('dblclick', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this._claimTabRailResize();
|
this._claimTabRailResize();
|
||||||
const preferred = window.CodemanTabRail?.DEFAULT_WIDTH || 256;
|
// Rich-aware: resetting a detailed rail to 256 would land it below the
|
||||||
|
// 288px tight threshold and silently drop the created stamp.
|
||||||
|
const preferred = this._defaultTabRailWidth?.() ?? (window.CodemanTabRail?.DEFAULT_WIDTH || 256);
|
||||||
const effective = this._setTabRailWidth(preferred);
|
const effective = this._setTabRailWidth(preferred);
|
||||||
this._scheduleTabRailSettle(effective, preferred);
|
this._scheduleTabRailSettle(effective, preferred);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1096,6 +1096,7 @@ export const SettingsUpdateSchema = z
|
|||||||
tabTwoRows: z.boolean().optional(),
|
tabTwoRows: z.boolean().optional(),
|
||||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||||
|
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||||
/**
|
/**
|
||||||
* Session list layout. Display key (per-device).
|
* Session list layout. Display key (per-device).
|
||||||
* 'header' = horizontal tab strip
|
* 'header' = horizontal tab strip
|
||||||
|
|||||||
@@ -125,6 +125,45 @@ describe('Inline rename input', () => {
|
|||||||
expect(renameStillActive).toBe(true);
|
expect(renameStillActive).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Escape cancels the rename instead of committing an empty name', async () => {
|
||||||
|
await resetState();
|
||||||
|
expect(await startRename('esc-cancel', 'rail-beta')).toBe(true);
|
||||||
|
|
||||||
|
// Escape used to clear the field and blur, and the blur handler commits —
|
||||||
|
// so cancelling a rename PUT an empty name, and the tab fell back to its
|
||||||
|
// folder label (measured against a live server, in the header strip as well
|
||||||
|
// as both vertical layouts). The observable here is the REQUEST: this
|
||||||
|
// harness's server has no such session, so a failed PUT would leave the
|
||||||
|
// local map looking innocent.
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
|
||||||
|
const calls: string[] = [];
|
||||||
|
const origFetch = window.fetch;
|
||||||
|
window.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
calls.push(String(input));
|
||||||
|
return new Response('{"success":true}', { status: 200 });
|
||||||
|
}) as typeof window.fetch;
|
||||||
|
|
||||||
|
const inputEl = document.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||||
|
inputEl.value = 'typed-but-abandoned';
|
||||||
|
inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
// The blur that follows the input's removal must not resurrect the commit.
|
||||||
|
inputEl.dispatchEvent(new Event('blur'));
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
window.fetch = origFetch;
|
||||||
|
return {
|
||||||
|
renamePuts: calls.filter((url) => url.includes('/api/sessions/esc-cancel/name')),
|
||||||
|
renameActive: !!app._activeRename,
|
||||||
|
inputStillInDom: document.body.contains(inputEl),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.renamePuts).toEqual([]);
|
||||||
|
expect(result.renameActive).toBe(false);
|
||||||
|
expect(result.inputStillInDom).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('CJK guard: regular Enter (no IME) DOES commit', async () => {
|
it('CJK guard: regular Enter (no IME) DOES commit', async () => {
|
||||||
await resetState();
|
await resetState();
|
||||||
expect(await startRename('regular-enter', 'OldName')).toBe(true);
|
expect(await startRename('regular-enter', 'OldName')).toBe(true);
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ interface LayoutApp {
|
|||||||
_fullRenderSessionTabs(): void;
|
_fullRenderSessionTabs(): void;
|
||||||
updateConnectionLines(): void;
|
updateConnectionLines(): void;
|
||||||
isSessionSidebarRich(): boolean;
|
isSessionSidebarRich(): boolean;
|
||||||
|
isTabRailRich(): boolean;
|
||||||
|
isRichTabRows(): boolean;
|
||||||
_sidebarRichRow(id: string, session: Record<string, unknown>): RichRow | null;
|
_sidebarRichRow(id: string, session: Record<string, unknown>): RichRow | null;
|
||||||
_sidebarRichMetaHTML(row: RichRow | null): string;
|
_sidebarRichMetaHTML(row: RichRow | null): string;
|
||||||
_updateSidebarRichRow(tab: Element, id: string, session: Record<string, unknown>): void;
|
_updateSidebarRichRow(tab: Element, id: string, session: Record<string, unknown>): void;
|
||||||
@@ -709,6 +711,22 @@ describe('rich session sidebar', () => {
|
|||||||
expect(html).toContain('data-i18n-skip');
|
expect(html).toContain('data-i18n-skip');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries both absolute stamps on the LINE, not only on the two items', () => {
|
||||||
|
// Below 288px the rail hides `.tab-meta-created` (tab-rail-tight), and a
|
||||||
|
// title on a `display: none` element has no hover target — so a tooltip
|
||||||
|
// living only there means the created stamp is gone, not shrunk. The line
|
||||||
|
// itself has to carry it for the CSS rule's "still reachable" to be true.
|
||||||
|
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
|
||||||
|
stubOverview(app);
|
||||||
|
const html = app._sidebarRichMetaHTML(app._sidebarRichRow('s1', SESSION));
|
||||||
|
|
||||||
|
const line = html.slice(0, html.indexOf('>'));
|
||||||
|
expect(line).toContain('class="tab-meta"');
|
||||||
|
expect(line).toContain('title="');
|
||||||
|
expect(line).toContain('First created');
|
||||||
|
expect(line).toContain('working');
|
||||||
|
});
|
||||||
|
|
||||||
it('drops the second stamp when the session has never been active', () => {
|
it('drops the second stamp when the session has never been active', () => {
|
||||||
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
|
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
|
||||||
stubOverview(app);
|
stubOverview(app);
|
||||||
@@ -795,7 +813,7 @@ describe('rich session sidebar', () => {
|
|||||||
// .tab-info is already a flex column, so the line needs no row-level
|
// .tab-info is already a flex column, so the line needs no row-level
|
||||||
// wrapping — and the collapsed 44px rail hides .tab-info wholesale, which is
|
// wrapping — and the collapsed 44px rail hides .tab-info wholesale, which is
|
||||||
// what keeps the stamps out of it for free.
|
// what keeps the stamps out of it for free.
|
||||||
expect(APP).toContain('const richRows = this.isSessionSidebarRich();');
|
expect(APP).toContain('const richRows = this.isRichTabRows();');
|
||||||
expect(APP).toContain('const richMeta = this._sidebarRichMetaHTML(richRow);');
|
expect(APP).toContain('const richMeta = this._sidebarRichMetaHTML(richRow);');
|
||||||
expect(APP).toContain('${richMeta}\n </span>');
|
expect(APP).toContain('${richMeta}\n </span>');
|
||||||
});
|
});
|
||||||
@@ -819,3 +837,150 @@ describe('rich session sidebar', () => {
|
|||||||
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
|
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('detailed rows in the vertical tab rail', () => {
|
||||||
|
/**
|
||||||
|
* The rail is the SECOND surface that draws rich rows. Everything about the
|
||||||
|
* row itself (model, markup, clock) is shared with the sidebar and covered
|
||||||
|
* above; what is new here is only the gate — which attribute turns it on,
|
||||||
|
* and the three ways it must turn back off.
|
||||||
|
*/
|
||||||
|
const railBoot = (stored: Record<string, unknown>) => {
|
||||||
|
const booted = boot({ stored: { sessionListLayout: 'header', ...stored } });
|
||||||
|
booted.app.applySessionListLayout();
|
||||||
|
booted.app.applyTabOrientation();
|
||||||
|
return booted;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('defaults the rail to detailed rows, since a docked column is not a tab strip', () => {
|
||||||
|
const { win, app } = railBoot({ tabOrientation: 'vertical' });
|
||||||
|
expect(win.document.documentElement.dataset.tabOrientation).toBe('vertical');
|
||||||
|
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
|
||||||
|
expect(app.isTabRailRich()).toBe(true);
|
||||||
|
expect(app.isRichTabRows()).toBe(true);
|
||||||
|
// The stamps go stale with no event behind them, so the clock has to run.
|
||||||
|
expect(app._sidebarRichClock).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors the 'simple' opt-out", () => {
|
||||||
|
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'simple' });
|
||||||
|
expect(win.document.documentElement.dataset.tabRailDetail).toBe('simple');
|
||||||
|
expect(app.isTabRailRich()).toBe(false);
|
||||||
|
expect(app.isRichTabRows()).toBe(false);
|
||||||
|
// Falsy rather than null: _stopSidebarRichClock() returns early when there
|
||||||
|
// is no interval to clear, which is the state a rail that never armed one is in.
|
||||||
|
expect(app._sidebarRichClock).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops back to simple rows once the rail is dragged into compact width', () => {
|
||||||
|
// Below 240px the rail already hides the row actions; three lines of stamps
|
||||||
|
// in a ~208px column ellipsize into noise. _setTabRailWidth() re-renders
|
||||||
|
// whenever this class flips, so the gate is re-read at the right moment.
|
||||||
|
const { win, app } = railBoot({ tabOrientation: 'vertical' });
|
||||||
|
expect(app.isTabRailRich()).toBe(true);
|
||||||
|
win.document.documentElement.classList.add('tab-rail-compact');
|
||||||
|
expect(app.isTabRailRich()).toBe(false);
|
||||||
|
expect(app.isRichTabRows()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never draws stamps in the horizontal header strip', () => {
|
||||||
|
// tabRailDetail stays 'rich' in storage while the orientation is horizontal:
|
||||||
|
// the gate has to read BOTH, or the header strip inherits a meta line that
|
||||||
|
// has nowhere to go.
|
||||||
|
const { win, app } = railBoot({ tabOrientation: 'horizontal', tabRailDetail: 'rich' });
|
||||||
|
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
|
||||||
|
expect(app.isTabRailRich()).toBe(false);
|
||||||
|
expect(app.isRichTabRows()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the simple sidebar simple even with the rail set to detailed', () => {
|
||||||
|
// The sidebar owns the tabs whenever it is active, which forces the
|
||||||
|
// orientation back to horizontal — so a rail preference must not leak a
|
||||||
|
// meta line into a list the user asked to keep compact.
|
||||||
|
const { app } = railBoot({ sessionListLayout: 'sidebar', tabOrientation: 'vertical', tabRailDetail: 'rich' });
|
||||||
|
expect(app.isSessionSidebarActive()).toBe(true);
|
||||||
|
expect(app.isSessionSidebarRich()).toBe(false);
|
||||||
|
expect(app.isTabRailRich()).toBe(false);
|
||||||
|
expect(app.isRichTabRows()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-renders when only the DETAIL changes, orientation untouched', () => {
|
||||||
|
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'simple' });
|
||||||
|
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
|
||||||
|
|
||||||
|
win.localStorage.setItem(
|
||||||
|
'codeman-app-settings',
|
||||||
|
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'vertical', tabRailDetail: 'rich' })
|
||||||
|
);
|
||||||
|
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
|
||||||
|
app.applyTabOrientation();
|
||||||
|
|
||||||
|
// The stamps line is emitted by the row template, not toggled by CSS: a
|
||||||
|
// missed render here means the setting repaints nothing until the next tick.
|
||||||
|
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
|
||||||
|
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
|
||||||
|
expect(app._sidebarRichClock).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders on the first call, when applyTabWrapSettings only sets its baseline', () => {
|
||||||
|
// The pre-paint script stamps the layout attributes; if it THREW it leaves
|
||||||
|
// them on the catch-branch fallbacks and applyTabOrientation() is the first
|
||||||
|
// thing to correct them, with `_tallTabsEnabled` still undefined.
|
||||||
|
// applyTabWrapSettings() renders only when it has a previous value to
|
||||||
|
// compare, so reading "the value changed" as "it rendered" skipped BOTH
|
||||||
|
// renders and left the rows stale.
|
||||||
|
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
|
||||||
|
expect(app._tallTabsEnabled).toBeUndefined();
|
||||||
|
expect(win.document.documentElement.getAttribute('data-tab-orientation')).toBeNull();
|
||||||
|
|
||||||
|
app.applyTabOrientation();
|
||||||
|
|
||||||
|
expect(win.document.documentElement.dataset.tabOrientation).toBe('vertical');
|
||||||
|
// The folder row turned on in the same pass, so this is exactly the case
|
||||||
|
// where the two guards could point at each other and neither fires.
|
||||||
|
expect(app._tallTabsEnabled).toBe(true);
|
||||||
|
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render twice when applyTabWrapSettings already did', () => {
|
||||||
|
// The mirror case: a detail flip that turns the folder row off makes
|
||||||
|
// applyTabWrapSettings() re-render, and applyTabOrientation() must not
|
||||||
|
// stack a second full rebuild of the strip on top of it.
|
||||||
|
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'rich' });
|
||||||
|
expect(app._tallTabsEnabled).toBe(true);
|
||||||
|
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
|
||||||
|
|
||||||
|
win.localStorage.setItem(
|
||||||
|
'codeman-app-settings',
|
||||||
|
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'vertical', tabRailDetail: 'simple' })
|
||||||
|
);
|
||||||
|
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
|
||||||
|
app.applyTabOrientation();
|
||||||
|
|
||||||
|
expect(app._tallTabsEnabled).toBe(false);
|
||||||
|
expect(app._fullRenderSessionTabs).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plumbs the rail detail through the settings UI, the schema and the pre-paint script', () => {
|
||||||
|
expect(INDEX_HTML).toContain('id="appSettingsTabRailDetail"');
|
||||||
|
expect(INDEX_HTML).toContain('<option value="rich">Detailed</option>');
|
||||||
|
// Pre-paint stamps it with the rest of the layout keys, or a detailed rail
|
||||||
|
// paints as a simple one for the first frame and then jumps a row taller.
|
||||||
|
expect(INDEX_HTML).toContain("dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich'");
|
||||||
|
expect(SETTINGS_UI).toContain("document.getElementById('appSettingsTabRailDetail').value");
|
||||||
|
const displayKeys = SETTINGS_UI.slice(SETTINGS_UI.indexOf('const displayKeys = new Set(['));
|
||||||
|
expect(displayKeys.slice(0, 1800)).toContain("'tabRailDetail'");
|
||||||
|
expect(SCHEMAS).toMatch(/tabRailDetail:\s*z\.enum\(\['simple',\s*'rich'\]\)\.optional\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every rich paint rule a rail twin without raising the sidebar arm', () => {
|
||||||
|
// Comma-grouped, never :is() — an :is() list takes its most specific
|
||||||
|
// argument, which would lift the sidebar selectors from (0,3,1) to the
|
||||||
|
// rail's (0,5,1) and let them outrank rules they never used to.
|
||||||
|
const rail = "html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail";
|
||||||
|
for (const suffix of ['.tab-meta', '.tab-meta-key', '.tab-pill', '.tab-pill--working']) {
|
||||||
|
expect(STYLES_CSS).toContain(`${rail} ${suffix}`);
|
||||||
|
}
|
||||||
|
expect(STYLES_CSS).not.toContain(':is(html[data-sidebar-detail="rich"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ describe('tab rail width policy', () => {
|
|||||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Home' })).toBe(208);
|
expect(policy.resolveKeyboardWidth({ ...base, key: 'Home' })).toBe(208);
|
||||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'End' })).toBe(360);
|
expect(policy.resolveKeyboardWidth({ ...base, key: 'End' })).toBe(360);
|
||||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter' })).toBe(256);
|
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter' })).toBe(256);
|
||||||
|
// Enter resets to the CALLER's effective default: a rich rail passes 320
|
||||||
|
// (its unsized rendering width), so the reset cannot land it below the
|
||||||
|
// 288px tight threshold the way a hardcoded 256 did.
|
||||||
|
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter', defaultWidth: 320 })).toBe(320);
|
||||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Escape' })).toBeNull();
|
expect(policy.resolveKeyboardWidth({ ...base, key: 'Escape' })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -123,6 +127,61 @@ describe('tab rail resize wiring', () => {
|
|||||||
expect(app._tabRailResizeOwnsObserver).toBe(false);
|
expect(app._tabRailResizeOwnsObserver).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-runs the wrap pass when the compact threshold flips, without double-rendering', () => {
|
||||||
|
const controller = readPublic('tab-rail-resize.js');
|
||||||
|
class FakeCodemanApp {}
|
||||||
|
const classes = new Set<string>();
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp: FakeCodemanApp,
|
||||||
|
window: { CodemanTabRail: loadRailPolicy() },
|
||||||
|
document: {
|
||||||
|
documentElement: {
|
||||||
|
style: { setProperty: () => {} },
|
||||||
|
classList: {
|
||||||
|
contains: (c: string) => classes.has(c),
|
||||||
|
toggle: (c: string, force: boolean) => {
|
||||||
|
if (force) classes.add(c);
|
||||||
|
else classes.delete(c);
|
||||||
|
return force;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelector: () => null,
|
||||||
|
},
|
||||||
|
console,
|
||||||
|
clearTimeout,
|
||||||
|
setTimeout,
|
||||||
|
});
|
||||||
|
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
|
||||||
|
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
|
||||||
|
app._getTabRailBounds = () => ({});
|
||||||
|
app.syncTabRailWidthSetting = vi.fn();
|
||||||
|
app._fullRenderSessionTabs = vi.fn();
|
||||||
|
|
||||||
|
// Rich rail dragged below 240px: applyTabWrapSettings() owns the folder
|
||||||
|
// line and reads the compact class this call just toggled, so it must be
|
||||||
|
// re-consulted on the flip — and when its own conditional render fires
|
||||||
|
// (the folder flag changed), the explicit render must not double it.
|
||||||
|
app._tallTabsEnabled = true;
|
||||||
|
app.applyTabWrapSettings = vi.fn(() => {
|
||||||
|
app._tallTabsEnabled = false;
|
||||||
|
app._fullRenderSessionTabs();
|
||||||
|
});
|
||||||
|
app._setTabRailWidth(210);
|
||||||
|
expect(app.applyTabWrapSettings).toHaveBeenCalledOnce();
|
||||||
|
expect(app._fullRenderSessionTabs).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// Flip back up with an unchanged folder flag (simple-detail rail): the
|
||||||
|
// explicit render must still fire — the compact row-action affordance
|
||||||
|
// changed even though the wrap pass rendered nothing.
|
||||||
|
app.applyTabWrapSettings = vi.fn();
|
||||||
|
app._fullRenderSessionTabs = vi.fn();
|
||||||
|
app._setTabRailWidth(300);
|
||||||
|
expect(app.applyTabWrapSettings).toHaveBeenCalledOnce();
|
||||||
|
expect(app._fullRenderSessionTabs).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps resize-observer ownership for pointer drags longer than the watchdog', async () => {
|
it('keeps resize-observer ownership for pointer drags longer than the watchdog', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const controller = readPublic('tab-rail-resize.js');
|
const controller = readPublic('tab-rail-resize.js');
|
||||||
|
|||||||
Reference in New Issue
Block a user