Merge christianhaberl's session-sidebar branch, ported to current master

Brings in https://github.com/christianhaberl/Codeman/pull/4 (three commits,
authorship preserved) and adapts it across the 211 commits master gained
since the branch was cut:

- App Settings control re-authored for the set-* surface (PR #278): a
  set-row in Layout -> Tabs, replacing the old settings-item markup the
  branch targeted. i18n description synced.
- Lineage arcs (PR #291, post-branch) are SKIPPED in sidebar layout:
  computeLineagePath()'s U-bridge geometry hangs from the horizontal
  strip's bottom edge and has no meaning against a vertical list. The
  lineage strip-scroll listener now also redraws subagent/ultracode
  connectors while the sidebar scrolls vertically.
- The desktop home tab rail (post-branch) defers to the sidebar: both dock
  the session list flush left, and the rail would render z-ordered under it.
- Active-row reveal unified into _scrollActiveTabIntoView() (#257 landed on
  master after the branch): sidebar mode branches to scrollIntoView
  block:'nearest', and _fullRenderSessionTabs() restores scrollTop alongside
  the #257 scrollLeft restore so ambient rebuilds cannot yank a mid-scroll
  sidebar back to the top.
- Mobile active-tab hoisting the branch guarded against no longer exists on
  master (removed by #257); kept master's order-stable render.

Verified: typecheck, lint, format:check, check:frontend-syntax,
check:public-assets, PostCSS parse of both merged stylesheets, the 26 new
jsdom tests, the structural guard suites, and the headless-Chromium harness
(scripts/verify-session-sidebar.mts) green across all seven layout states
at 1600/1000/393px against current master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 18:28:15 +02:00
20 changed files with 1656 additions and 56 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* Manual verification harness for the session-sidebar feature.
*
* Renders the real UI in headless Chromium against a testMode WebServer,
* injects a synthetic 25-session fleet, and screenshots every layout state.
* Not part of the automated suite — run it by hand:
*
* npx tsx scripts/verify-session-sidebar.mts
*
* SAFETY: uses the repo's own test harness (temp HOME, testMode server) on a
* dedicated port. It never touches a real Codeman instance or tmux socket.
*/
import { chromium } from 'playwright';
import { WebServer } from '../src/web/server.js';
import { mkdirSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Mirror test/setup.ts: isolate HOME before the app modules touch state.
process.env.HOME = mkdtempSync(join(tmpdir(), 'codeman-sidebar-verify-'));
process.env.VITEST = 'true';
const PORT = 3299;
const OUT = process.env.SIDEBAR_SHOTS_DIR ?? join(tmpdir(), 'codeman-sidebar-shots');
mkdirSync(OUT, { recursive: true });
// Generic on purpose: these names end up in the harness screenshots, so they
// should not carry one contributor's project list into everyone else's review.
// The mix of CLI modes matters (each renders a different badge); the names do not.
const PROJECTS = [
['api-server', 'claude'],
['web-client', 'claude'],
['mobile-app', 'codex'],
['data-pipeline', 'claude'],
['shared-lib', 'gemini'],
['codeman', 'claude'],
['docs-site', 'claude'],
['batch-jobs', 'opencode'],
['search-index', 'claude'],
];
const STATUSES = ['idle', 'busy', 'idle', 'busy', 'error', 'idle'];
function fleet(n: number) {
const out: any[] = [];
for (let i = 0; i < n; i++) {
const [proj, mode] = PROJECTS[i % PROJECTS.length];
const status = STATUSES[i % STATUSES.length];
out.push({
id: `sess-${String(i).padStart(4, '0')}-aaaa-bbbb-cccc-dddddddddddd`,
pid: 10000 + i,
status,
workingDir: `${tmpdir()}/projects/${proj}`,
name: `${proj}${i > 8 ? '-' + Math.floor(i / 9) : ''}`,
mode,
currentTaskId: null,
createdAt: Date.now() - i * 60000,
lastActivityAt: Date.now() - i * 1000,
isWorking: status === 'busy',
messageCount: i * 3,
totalCost: 0,
inputTokens: 0,
outputTokens: 0,
color: 'default',
taskStats: { total: i % 4, running: i % 3 === 0 ? 2 : 0, completed: 0, failed: 0 },
taskTree: [],
tokens: { input: 0, output: 0, total: 0 },
bufferStats: { terminalBufferSize: 0, textOutputSize: 0, messageCount: 0 },
});
}
return out;
}
const SESSIONS = fleet(25);
async function main() {
const server = new WebServer(PORT, false, true);
await server.start();
const browser = await chromium.launch({ headless: true });
const results: string[] = [];
async function shot(
name: string,
opts: { layout: 'header' | 'sidebar'; collapsed?: boolean; width: number; height: number; touch?: boolean }
) {
const ctx = await browser.newContext({
viewport: { width: opts.width, height: opts.height },
hasTouch: !!opts.touch,
isMobile: !!opts.touch,
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
const settings = JSON.stringify({ sessionListLayout: opts.layout });
const collapsed = opts.collapsed === undefined ? null : opts.collapsed ? '1' : '0';
await page.addInitScript(
([s, c]) => {
localStorage.setItem('codeman-app-settings', s as string);
localStorage.setItem('codeman-app-settings-mobile', s as string);
if (c !== null) localStorage.setItem('codeman-sidebar-collapsed', c as string);
else localStorage.removeItem('codeman-sidebar-collapsed');
},
[settings, collapsed]
);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
await page.evaluate((list) => {
const app = (window as any).app;
if (!app) throw new Error('no window.app');
app.sessions.clear();
for (const s of list as any[]) app.sessions.set(s.id, s);
// The renderer iterates sessionOrder, not the map.
app.sessionOrder = (list as any[]).map((s) => s.id);
app.activeSessionId = (list as any[])[3].id;
// renderSessionTabs() is debounced; drive the immediate path directly.
(app._fullRenderSessionTabs ?? app._renderSessionTabsImmediate)?.call(app);
app.applySessionListLayout?.();
}, SESSIONS as any);
await page.waitForTimeout(600);
const info = await page.evaluate(() => {
const root = document.documentElement;
const aside = document.getElementById('sessionSidebar');
const tabsEl = document.getElementById('sessionTabs');
const asideBox = aside?.getBoundingClientRect();
const cs = aside ? getComputedStyle(aside) : null;
return {
dataSessionList: root.dataset.sessionList ?? null,
dataSidebar: root.dataset.sidebar ?? null,
rows: document.querySelectorAll('.session-tab').length,
tabsParent: tabsEl?.parentElement?.id || tabsEl?.parentElement?.className || null,
asideWidth: asideBox ? Math.round(asideBox.width) : null,
asideVisible: cs ? cs.display !== 'none' && cs.visibility !== 'hidden' : null,
asideInert: aside?.hasAttribute('inert') ?? null,
ariaHidden: aside?.getAttribute('aria-hidden') ?? null,
toggleAriaExpanded: document.getElementById('sidebarToggleBtn')?.getAttribute('aria-expanded') ?? null,
firstRowText:
(document.querySelector('.session-tab') as HTMLElement | null)?.innerText
?.trim()
.replace(/\s+/g, ' ')
.slice(0, 40) ?? null,
listScrollable: (() => {
const el = document.getElementById('sessionTabs');
return el ? el.scrollHeight > el.clientHeight + 2 : null;
})(),
};
});
await page.waitForTimeout(400);
const file = join(OUT, `${name}.png`);
await page.screenshot({ path: file });
results.push(`${name.padEnd(28)} ${JSON.stringify(info)}`);
await ctx.close();
return info;
}
await shot('01-header-desktop', { layout: 'header', width: 1600, height: 900 });
await shot('02-sidebar-expanded', { layout: 'sidebar', collapsed: false, width: 1600, height: 900 });
await shot('03-sidebar-collapsed-rail', { layout: 'sidebar', collapsed: true, width: 1600, height: 900 });
await shot('04-sidebar-narrow-1000', { layout: 'sidebar', collapsed: true, width: 1000, height: 800 });
await shot('05-sidebar-drawer-open-1000', { layout: 'sidebar', collapsed: false, width: 1000, height: 800 });
await shot('06-sidebar-phone-closed', { layout: 'sidebar', collapsed: true, width: 393, height: 852, touch: true });
await shot('07-sidebar-phone-open', { layout: 'sidebar', collapsed: false, width: 393, height: 852, touch: true });
console.log('\n=== RESULTS ===');
for (const r of results) console.log(r);
console.log(`\nScreenshots in ${OUT}`);
await browser.close();
await server.stop();
}
main().then(
() => process.exit(0),
(e) => {
console.error(e);
process.exit(1);
}
);