diff --git a/src/web/public/app.js b/src/web/public/app.js index 3ae3c330..73eee32c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4202,7 +4202,21 @@ class CodemanApp { parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since')); } parts.push(`${escapeHtml(row.pill)}`); - return `${parts.join('')}`; + // 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 `${parts.join('')}`; } /** Same formatter as both home screens, so a duration is written the same way everywhere. */ diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 9978ade3..5f07f3a8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2753,7 +2753,15 @@ Object.assign(CodemanApp.prototype, { const prevTall = this._tallTabsEnabled; if (changed) this.applyTabWrapSettings?.(); if (changed) { - if (prevTall === this._tallTabsEnabled) this._fullRenderSessionTabs?.(); + // 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._refreshHomeSessionsIfVisible?.(); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4abc13e0..e4ebf4e7 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -17285,7 +17285,10 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail /* Narrower than the detailed default (the .tab-rail-tight class, _setTabRailWidth): the created stamp is dropped rather than shown as - "CREA…". Its value survives in the row's title attribute either way. */ + "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; diff --git a/test/session-list-layout.test.ts b/test/session-list-layout.test.ts index bb0acab1..44898db5 100644 --- a/test/session-list-layout.test.ts +++ b/test/session-list-layout.test.ts @@ -711,6 +711,22 @@ describe('rich session sidebar', () => { 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', () => { const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } }); stubOverview(app); @@ -906,6 +922,45 @@ describe('detailed rows in the vertical tab rail', () => { 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('');