From a06b0a550a4d5f739ea06c607cd703728a68a6ab Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:17:45 +0200 Subject: [PATCH] feat(tabs): many tabs in the top bar: icons first, then a +N list (0.53.0, closes #146) Owner, 2026-10-15: "lets tackle when theres very many tabs"; of the four mockups (scroll with arrows, a list, icons then a list, extra rows) the owner picked "C: icons, then list". - lib/stripPlan.ts: planStrip, pure and tested. Every tab named down to the 84 px floor; then tabs at rest become their icon (40 px) while the tab in front and the tabs that need you (Finished, Question, Failed) keep their names; then those become icons too, newest first; then tabs wait in a +N list, resting ones first. The tab in front is never an icon or hidden. - TabStrip measures its room (ResizeObserver) and draws full, icon or nothing per the plan; the strip no longer scrolls. An icon tab keeps its name as tooltip and accessible name, its ring on the icon's corner. - The +N button: the count, a dot in its mark colour for each hidden tab that needs you, and TabOverflowList: Find field focused, Needs you first, a pick brings the tab in front, a right click opens its menu. - Ctrl+Shift+K (Find a tab) now works in the top-bar layout too: the list with every tab in it. - e2e manyTabs (16 tabs in a 700 px window) and the spec in docs/superpowers/specs/2026-10-15-many-tabs-design.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017XYVRPzvhLpPCr75uB6uaA --- .../specs/2026-10-15-many-tabs-design.md | 87 ++++++++++ package-lock.json | 4 +- package.json | 2 +- src/renderer/src/App.tsx | 3 +- .../src/components/TabOverflowList.tsx | 144 +++++++++++++++++ src/renderer/src/components/TabStrip.tsx | 152 +++++++++++++++++- src/renderer/src/lib/keymap.test.ts | 4 +- src/renderer/src/lib/keymap.ts | 5 +- src/renderer/src/lib/stripPlan.test.ts | 60 +++++++ src/renderer/src/lib/stripPlan.ts | 66 ++++++++ tools/e2e/run.mjs | 72 +++++++++ 11 files changed, 584 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-many-tabs-design.md create mode 100644 src/renderer/src/components/TabOverflowList.tsx create mode 100644 src/renderer/src/lib/stripPlan.test.ts create mode 100644 src/renderer/src/lib/stripPlan.ts diff --git a/docs/superpowers/specs/2026-10-15-many-tabs-design.md b/docs/superpowers/specs/2026-10-15-many-tabs-design.md new file mode 100644 index 0000000..1d8ba83 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-many-tabs-design.md @@ -0,0 +1,87 @@ +# Many tabs in the top bar (#146) + +Owner, 2026-10-10: "we don't really have a system to control what happens when there are a lot of +tabs. Currently they just squeeze together more and more and you get a scroll bar". 2026-10-15: "lets +tackle when theres very many tabs". Mockup round the same day (four options: scroll with arrows, a +list, icons then a list, extra rows); **the owner picked C, icons first, then a list**. + +Scope: the horizontal tab bar only (`TabStrip`). The sidebar layout already lists any number of tabs. +App shell (`src/`), not the core: Prism has its own strip. + +## What the bar does + +The bar lays tabs out in up to three steps, each only when the one before has run out of room. + +1. **As today:** every tab one width, 150 px, shrinking equally to its floor of 84 px. +2. **Icons:** when the tabs no longer fit at 84 px, tabs at rest shrink to their icon (36 px), with + their mark (ring or Bar) on the icon's corner. These keep their name: + - the tab in front; + - tabs that need you: Finished, Question, Failed. + A working tab at rest is an icon too; its spinner stays on the corner. + If the named tabs plus the icons still don't fit, the tabs that need you become icons as well + (their marks stay on screen). The tab in front always keeps its name. +3. **The list:** when even the icons don't fit, tabs go behind a **+N** button at the end of the bar, + just before the **+**. Resting tabs go first, newest first; a tab that needs you goes only when + no resting tab is left. The tab in front is never in the list. + +Order never changes: tabs keep their place in the bar, a hidden tab simply is not drawn. + +## The +N button and its list + +- **The button:** `+N` and a small dot for each hidden tab that needs you, in its mark colour (up to + four), and a chevron. Its tooltip says "N more tabs". +- **The list:** opens under the button. A **Find a tab** field at the top, focused, filters by name + and folder as you type; Enter picks the first match, Escape closes. +- Rows: the agent icon, the name, the folder (dim), and the tab's mark. Tabs that need you come first + under **Needs you**, the rest under **Other tabs**, each group in bar order. +- Picking a row makes that tab the one in front. It then shows in the bar (the tab in front is + always shown), and whichever tab no longer fits goes into the list. +- A right click on a row opens that tab's usual menu. + +## Keys + +- **Ctrl+Shift+K (Find a tab)** in the top-bar layout opens the list with its field focused, with + every tab in it (not only the hidden ones), so it finds any tab. Today it does nothing in this + layout. +- Ctrl+Tab, Ctrl+1..9 and the rest work as now; a hidden tab they pick comes into the bar. + +## Details + +- An icon tab's tooltip is the same as a named tab's: the name, the folder, the state (Tooltips, + placed by the whole tab since #223). +- An icon tab is a drag handle like any tab (`useTabReorder`). Hidden tabs are not draggable; the + list does not reorder. +- An icon tab has no close button (40 px holds the icon and its mark): it closes with a middle click, + its right-click menu or Ctrl+W, as any tab does. +- The layout is recomputed from the bar's measured width (a ResizeObserver) and the tab list, never + from a timer. Tabs do not animate between steps (no motion on resize). +- Screen readers: icon tabs keep their name as the accessible name; the +N button is a menu button + ("12 more tabs"); the list is a `listbox` with the field as its combobox. + +## Out of scope + +- The sidebar, Prism's strip, a suggestion to switch to the sidebar. +- Pinned tabs, tab groups. + +## Tests + +- Unit: `planStrip` (pure): widths in, `full | icon | hidden` per tab out. Cases: fits; icons start; + needs-you keep names; needs-you become icons before anything hides; resting tabs hide first, + newest first; the tab in front is never an icon or hidden; one huge count. +- e2e (parked, headless): 30 tabs in a 1100 px window: no horizontal scrollbar on the strip; icon + tabs present; the tab in front named; a +N button whose count plus the drawn tabs equals 30; + opening the list and picking a row brings that tab in front and into the bar; Ctrl+Shift+K opens + the list with its field focused. Screenshot `.e2e-shots/strip-many.png` to look at. + +# Plan + +1. `src/renderer/src/lib/stripPlan.ts`: `planStrip({ tabs, activeId, needs, room })` plus tests. +2. `TabStrip.tsx`: measure the strip (ResizeObserver), call `planStrip`, draw `full` tabs as now, + `icon` tabs as a compact variant (icon, mark on the corner, no close button), skip `hidden`; + add the `+N` button. Remove `overflow-x-auto` from the strip. +3. `src/renderer/src/components/TabOverflowList.tsx`: the list (field, groups, rows, keyboard), + reusing the menu panel's surface and `TabIcon` / `TabMark`. +4. `App.tsx`: `find-tab` in the top-bar layout opens the list (an event the strip listens for, as the + sidebar does). +5. e2e scenario `manyTabs` in `tools/e2e/run.mjs`. +6. Version bump (app only), PR, install for the owner to try, merge on the owner's yes. diff --git a/package-lock.json b/package-lock.json index 692fd92..05863d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.52.3", + "version": "0.53.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.52.3", + "version": "0.53.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index c2e7bdc..1a5a3de 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.52.3", + "version": "0.53.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5b8bf43..dbb3fcd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -632,7 +632,8 @@ export default function App(): JSX.Element { setState((s) => toggleSettings(s, backFromSettings(s))) return case 'find-tab': - // FIND A TAB (#162): the sidebar's find box. + // FIND A TAB (#162): the sidebar's find box, or in the top bar the + // +N list with every tab in it (#146). hit() window.dispatchEvent(new Event('prism:find-tab')) return diff --git a/src/renderer/src/components/TabOverflowList.tsx b/src/renderer/src/components/TabOverflowList.tsx new file mode 100644 index 0000000..8f8c099 --- /dev/null +++ b/src/renderer/src/components/TabOverflowList.tsx @@ -0,0 +1,144 @@ +import { useEffect, useLayoutEffect, useRef, useState, type JSX, type MouseEvent, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { LAYER } from '@core/renderer/components/layers' +import { findKeeps, folderLine } from '../lib/sidebarRows' +import { useHomeDir } from '../lib/appFacts' + +/** + * THE +N LIST (#146; owner, 2026-10-15, option C of the many-tabs mockups): + * the tabs the top bar had no room for, with a Find field. The tabs that need + * you come first. Ctrl+Shift+K opens it with EVERY tab in it, so Find a tab + * works in the top-bar layout too (it was the sidebar's alone). + */ + +export interface OverflowRow { + id: string + label: string + cwd: string + icon: ReactNode + /** The tab's ring, or null. */ + mark: ReactNode | null + /** Finished, Question or Failed: listed first. */ + needs: boolean +} + +export function TabOverflowList({ + rows, + anchor, + onPick, + onRowMenu, + onClose +}: { + rows: OverflowRow[] + /** The +N button's box: the list opens under its right edge. */ + anchor: DOMRect + onPick: (id: string) => void + onRowMenu: (e: MouseEvent, id: string) => void + onClose: () => void +}): JSX.Element { + const home = useHomeDir() + const [query, setQuery] = useState('') + const box = useRef(null) + const field = useRef(null) + const [pos, setPos] = useState({ x: anchor.right, y: anchor.bottom + 4 }) + const kept = rows.filter((r) => findKeeps(query, r.label, r.cwd)) + const needs = kept.filter((r) => r.needs) + const rest = kept.filter((r) => !r.needs) + + useLayoutEffect(() => { + const el = box.current + if (!el) return + const w = el.getBoundingClientRect().width + setPos({ x: Math.max(8, Math.min(anchor.right - w, window.innerWidth - w - 8)), y: anchor.bottom + 4 }) + }, [anchor]) + useEffect(() => { + field.current?.focus() + const onDown = (e: PointerEvent): void => { + if (box.current && e.target instanceof Node && box.current.contains(e.target)) return + onClose() + } + window.addEventListener('pointerdown', onDown, true) + window.addEventListener('blur', onClose) + return () => { + window.removeEventListener('pointerdown', onDown, true) + window.removeEventListener('blur', onClose) + } + }, [onClose]) + + const row = (r: OverflowRow): JSX.Element => ( + + ) + const head = (text: string): JSX.Element => ( +
{text}
+ ) + + return createPortal( +
{ + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + }} + > +
+ + + + + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && kept.length) { + const first = needs[0] ?? rest[0] + onPick(first.id) + onClose() + } + }} + className="min-w-0 flex-1 bg-transparent text-[12.5px] text-[var(--p-text)] outline-none placeholder:text-[var(--p-dim2)]" + /> +
+
+ {needs.length > 0 && head('Needs you')} + {needs.map(row)} + {rest.length > 0 && head(needs.length ? 'Other tabs' : 'Tabs')} + {rest.map(row)} + {!kept.length &&
No tab has that name or folder.
} +
+
, + document.body + ) +} diff --git a/src/renderer/src/components/TabStrip.tsx b/src/renderer/src/components/TabStrip.tsx index 2862f48..f1e6b74 100644 --- a/src/renderer/src/components/TabStrip.tsx +++ b/src/renderer/src/components/TabStrip.tsx @@ -1,5 +1,7 @@ -import { useEffect, useRef, useState, type JSX, type MouseEvent } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type JSX, type MouseEvent } from 'react' import { tabLabels } from '../lib/tabs' +import { planStrip } from '../lib/stripPlan' +import { TabOverflowList, type OverflowRow } from './TabOverflowList' import { DictationTabMark } from '@core/renderer/components/DictationTabMark' import { TabMark } from '@core/renderer/components/TabMark' import { useTabLooks } from './tabLooks' @@ -76,11 +78,57 @@ export function TabStrip({ }, []) const menu = useTabMenus({ onClose, onOpenRecent }) const strip = useRef(null) + const plus = useRef(null) + const moreBtn = useRef(null) + // MANY TABS (#146): the room the tabs have, measured, so the plan below can + // say which are named, which are icons and which wait in the +N list. + const [room, setRoom] = useState(Number.POSITIVE_INFINITY) + const anyTabs = tabs.length > 0 + useLayoutEffect(() => { + const el = strip.current + if (!el) return + const measure = (): void => setRoom(el.clientWidth - 4 - (plus.current?.offsetWidth ?? 28) - 4) + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + return () => ro.disconnect() + }, [anyTabs]) + /** The open +N list: its anchor, and whether it lists every tab (Find a tab). */ + const [list, setList] = useState<{ anchor: DOMRect; all: boolean } | null>(null) + const closeList = useCallback((): void => setList(null), []) + // FIND A TAB in this layout (#146): the list with every tab in it. + useEffect(() => { + const go = (): void => { + const anchor = moreBtn.current?.getBoundingClientRect() ?? plus.current?.getBoundingClientRect() + if (anchor) setList({ anchor, all: true }) + } + window.addEventListener('prism:find-tab', go) + return () => window.removeEventListener('prism:find-tab', go) + }, []) // Carried along the row, never out of it (useTabReorder, the sidebar's too). const reorder = useTabReorder({ axis: 'x', container: strip, onReorder }) const { carry } = reorder if (!tabs.length) return null const labels = tabLabels(tabs) + const needs = new Set([...doneIds, ...questionIds, ...(failedIds ?? [])]) + const plan = planStrip({ ids: tabs.map((t) => t.id), activeId, needs, room }) + const hiddenIdx = plan.flatMap((s, i) => (s === 'hidden' ? [i] : [])) + /** Tab i's ring mark, or null: the end slot's and the +N list's. */ + const ringOf = (i: number): JSX.Element | null => { + const { state, mark } = looks[i] + return mark.place === 'ring' ? : null + } + const rowOf = (i: number): OverflowRow => { + const t = tabs[i] + return { + id: t.id, + label: labels[i], + cwd: t.cwd, + icon: , + mark: looks[i].state ? ringOf(i) : null, + needs: needs.has(t.id) + } + } return (
{tabs.map((t, i) => { + if (plan[i] === 'hidden') return null const on = t.id === activeId const { failed, state, mark } = looks[i] // The tab in front is told by its brighter ink. @@ -114,11 +163,7 @@ export function TabStrip({ // still coming back to its agent (#106) wears its loading ring there // too (owner, 2026-10-13: "make that ring appear where the ring style // agent indicator goes, not on the left side"). - const ring = loadingIds?.has(t.id) ? ( - - ) : mark.place === 'ring' ? ( - - ) : null + const ring = loadingIds?.has(t.id) ? : ringOf(i) const body = ( <> {bar} @@ -167,6 +212,47 @@ export function TabStrip({ ...reorder.handlers(t.id, i) } const motion = reorder.motion(t.id, i) + if (plan[i] === 'icon') + return ( + // AN ICON TAB (#146): a tab at rest when names no longer fit. Its + // name is its tooltip and its accessible name; its mark sits on the + // icon's corner; it closes from its menu, a middle click or Ctrl+W. +
+ {bar} + {t.kind !== 'settings' && } + + {ring && ( + + {ring} + + )} + +
+ ) return (
) })} + {hiddenIdx.length > 0 && ( + // THE +N BUTTON (#146): how many tabs wait in the list, and a dot in + // its mark colour for each that needs you (up to four). + + )} {menu.menus} + {list && ( + i) : hiddenIdx).map(rowOf)} + anchor={list.anchor} + onPick={onPick} + onRowMenu={(e, id) => { + const t = tabs.find((x) => x.id === id) + if (t) menu.openTabMenu(e, t) + }} + onClose={closeList} + /> + )}
) } diff --git a/src/renderer/src/lib/keymap.test.ts b/src/renderer/src/lib/keymap.test.ts index b162426..73913a9 100644 --- a/src/renderer/src/lib/keymap.test.ts +++ b/src/renderer/src/lib/keymap.test.ts @@ -61,10 +61,10 @@ describe('the keymap', () => { expect(chordOf(ev('F1', { shiftKey: true }), on)).toBeNull() }) - it('the sidebar and find-a-tab keys only while the tabs are vertical', () => { + it('the sidebar key only while the tabs are vertical; find-a-tab in both layouts (#146)', () => { const flat = { ...on, vertical: false } expect(chordOf(ev('B', { ctrlKey: true, shiftKey: true }), flat)).toBeNull() - expect(chordOf(ev('K', { ctrlKey: true, shiftKey: true }), flat)).toBeNull() + expect(chordOf(ev('K', { ctrlKey: true, shiftKey: true }), flat)).toBe('find-tab') expect(chordOf(ev('b', { ctrlKey: true, shiftKey: true }), on)).toBe('sidebar') }) diff --git a/src/renderer/src/lib/keymap.ts b/src/renderer/src/lib/keymap.ts index 0940ed5..f51d6cb 100644 --- a/src/renderer/src/lib/keymap.ts +++ b/src/renderer/src/lib/keymap.ts @@ -92,10 +92,11 @@ export const CHORDS: Record = { 'jump-tab': { label: 'Ctrl+1..9', match: (e) => ctrl(e) && !e.shiftKey && /^[1-9]$/.test(e.key), underModal: 'swallow' }, settings: { label: 'Ctrl+,', match: (e) => ctrl(e) && !e.shiftKey && e.key === ',', underModal: 'swallow' }, // FIND A TAB, Ctrl+Shift+K (the mockup said Ctrl+K; that is the shell's - // kill-to-end-of-line, in readline and PSReadLine alike), while vertical. + // kill-to-end-of-line, in readline and PSReadLine alike). In both layouts + // since #146: in the top bar it opens the +N list with every tab in it. 'find-tab': { label: 'Ctrl+Shift+K', - match: (e, c) => ctrl(e) && e.shiftKey && k(e) === 'k' && c.vertical, + match: (e) => ctrl(e) && e.shiftKey && k(e) === 'k', underModal: 'swallow' }, // THE SIDEBAR, Ctrl+Shift+B (owner, 2026-10-12: "bind the sidebar to a diff --git a/src/renderer/src/lib/stripPlan.test.ts b/src/renderer/src/lib/stripPlan.test.ts new file mode 100644 index 0000000..e17290d --- /dev/null +++ b/src/renderer/src/lib/stripPlan.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { ICON_TAB, MORE_BUTTON, TAB_FLOOR, planStrip, type StripSlot } from './stripPlan' + +const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `t${i}`) +const count = (s: StripSlot[], k: StripSlot): number => s.filter((x) => x === k).length +/** The width a plan takes, as TabStrip lays it out at the floor. */ +const used = (s: StripSlot[]): number => + count(s, 'full') * TAB_FLOOR + count(s, 'icon') * ICON_TAB + (count(s, 'hidden') ? MORE_BUTTON : 0) + +describe('planStrip (#146: icons first, then the list)', () => { + it('names every tab while they fit at the floor', () => { + expect(planStrip({ ids: ids(10), activeId: 't0', needs: new Set(), room: 10 * TAB_FLOOR })).toEqual(Array(10).fill('full')) + }) + + it('past the floor, resting tabs become icons; the tab in front and those that need you keep names', () => { + const s = planStrip({ ids: ids(12), activeId: 't5', needs: new Set(['t2', 't9']), room: 900 }) + expect(s[5]).toBe('full') + expect(s[2]).toBe('full') + expect(s[9]).toBe('full') + expect(count(s, 'icon')).toBe(9) + expect(count(s, 'hidden')).toBe(0) + expect(used(s)).toBeLessThanOrEqual(900) + }) + + it('the tabs that need you become icons, newest first, before any tab hides', () => { + const needs = new Set(ids(12)) + const s = planStrip({ ids: ids(12), activeId: 't0', needs, room: TAB_FLOOR * 3 + ICON_TAB * 9 }) + expect(s.slice(0, 3)).toEqual(['full', 'full', 'full']) + expect(s.slice(3).every((x) => x === 'icon')).toBe(true) + }) + + it('then resting tabs go into the list, newest first, and those that need you last', () => { + const s = planStrip({ ids: ids(40), activeId: 't0', needs: new Set(['t39', 't1']), room: 1000 }) + expect(count(s, 'hidden')).toBeGreaterThan(0) + expect(s[39]).not.toBe('hidden') + expect(s[1]).not.toBe('hidden') + // Resting tabs hide from the end: the first hidden one has only hidden + // resting tabs (or the two that need you) after it. + const first = s.indexOf('hidden') + expect(s.slice(first).every((x, k) => x === 'hidden' || ['t39'].includes(`t${first + k}`))).toBe(true) + expect(used(s)).toBeLessThanOrEqual(1000) + }) + + it('the tab in front is never an icon and never hidden, wherever it sits', () => { + for (const active of ['t0', 't20', 't39']) { + const s = planStrip({ ids: ids(40), activeId: active, needs: new Set(), room: 300 }) + expect(s[Number(active.slice(1))]).toBe('full') + } + }) + + it('a bar too narrow for anything still shows the tab in front', () => { + const s = planStrip({ ids: ids(5), activeId: 't3', needs: new Set(['t1']), room: 10 }) + expect(s[3]).toBe('full') + expect(count(s, 'hidden')).toBe(4) + }) + + it('no tabs, no plan', () => { + expect(planStrip({ ids: [], activeId: null, needs: new Set(), room: 500 })).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/stripPlan.ts b/src/renderer/src/lib/stripPlan.ts new file mode 100644 index 0000000..b8def61 --- /dev/null +++ b/src/renderer/src/lib/stripPlan.ts @@ -0,0 +1,66 @@ +/** + * HOW THE TOP BAR FITS MANY TABS (#146; owner, 2026-10-15, option C of the + * mockups: "icons first, then the list"). Pure, so the rule has tests. + * + * In three steps, each only when the one before has run out of room: + * 1. every tab named, shrinking equally to its floor (as before); + * 2. tabs at rest become their icon; the tab in front and the tabs that need + * you (Finished, Question, Failed) keep their names, and if that is still + * too wide, the ones that need you become icons too, newest first; + * 3. tabs go into the +N list: resting ones first, newest first, then those + * that need you. The tab in front is never an icon and never hidden. + * Order never changes: a hidden tab is simply not drawn. + */ + +export type StripSlot = 'full' | 'icon' | 'hidden' + +/** A named tab's floor (TabStrip's min width since 2026-10-12). */ +export const TAB_FLOOR = 84 +/** An icon tab: the 18 px icon and 11 px either side. */ +export const ICON_TAB = 40 +/** The +N button, with its count, dots and chevron. */ +export const MORE_BUTTON = 72 + +export function planStrip({ + ids, + activeId, + needs, + room +}: { + ids: readonly string[] + activeId: string | null + /** Tabs that need you: they keep their names longest, and hide last. */ + needs: ReadonlySet + /** The strip's width for tabs and the +N button (the + left out). */ + room: number +}): StripSlot[] { + const n = ids.length + if (n * TAB_FLOOR <= room) return ids.map(() => 'full') + const slot: StripSlot[] = ids.map((id) => (id === activeId || needs.has(id) ? 'full' : 'icon')) + const width = (): number => { + let w = 0 + let hidden = false + for (const s of slot) { + if (s === 'full') w += TAB_FLOOR + else if (s === 'icon') w += ICON_TAB + else hidden = true + } + return w + (hidden ? MORE_BUTTON : 0) + } + const fits = (): boolean => width() <= room + // Newest first: the end of the bar gives way before its start. + const order = ids.map((_, i) => n - 1 - i) + // 2b. The tabs that need you become icons: their marks stay on screen. + for (const i of order) { + if (fits()) return slot + if (slot[i] === 'full' && ids[i] !== activeId) slot[i] = 'icon' + } + // 3. Into the list: resting tabs first, then those that need you. + for (const wanted of [false, true]) { + for (const i of order) { + if (fits()) return slot + if (slot[i] === 'icon' && needs.has(ids[i]) === wanted) slot[i] = 'hidden' + } + } + return slot +} diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 7b116bf..9a4ef39 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2906,6 +2906,78 @@ const scenarios = { } }, + /** + * MANY TABS (#146; owner, 2026-10-15, option C: "icons first, then the + * list"). Sixteen tabs in a narrow window: the strip never scrolls, resting + * tabs are icons, the tab in front keeps its name, the rest wait behind +N, + * whose list brings a tab in front; Ctrl+Shift+K opens it with every tab. + */ + async manyTabs(ok) { + const w = world() + const root = dirname(w.alpha) + const names = Array.from({ length: 16 }, (_, i) => `proj${String(i + 1).padStart(2, '0')}`) + const dirs = names.map((n) => { + const d = join(root, n) + mkdirSync(d, { recursive: true }) + return d + }) + const { app, page } = await launch(w, { args: dirs }) + try { + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(700, 600)) + await until(async () => (await page.locator('[data-tab]').count()) + Number((await page.locator('[data-tab-more]').textContent().catch(() => '+0'))?.match(/\d+/)?.[0] ?? 0) === 16, 30000, 100) + await sleep(400) + const look = () => + page.evaluate(() => { + const strip = document.querySelector('[data-tab-strip]') + const tabs = [...document.querySelectorAll('[data-tab-strip] [data-tab]')] + const more = document.querySelector('[data-tab-more]') + return { + scrolls: strip ? strip.scrollWidth > strip.clientWidth + 1 : null, + drawn: tabs.length, + icons: tabs.filter((t) => t.hasAttribute('data-tab-icon-only')).length, + activeNamed: tabs.some((t) => t.hasAttribute('data-tab-active') && !t.hasAttribute('data-tab-icon-only')), + more: more ? Number(more.textContent.match(/\d+/)?.[0] ?? 0) : 0, + active: document.querySelector('[data-tab-active] [role="tab"]')?.getAttribute('data-label') ?? document.querySelector('[data-tab-active]')?.textContent?.trim() + } + }) + const a = await look() + ok(a.scrolls === false, `the strip never scrolls (${JSON.stringify(a)})`) + ok(a.icons > 0, `resting tabs are icons (${a.icons})`) + ok(a.activeNamed, 'the tab in front keeps its name') + ok(a.more > 0 && a.drawn + a.more === 16, `the rest wait behind +N (${a.drawn} drawn + ${a.more} listed = 16)`) + await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/strip-many.png'), clip: { x: 0, y: 0, width: 700, height: 80 } }).catch(() => {}) + + // The list: the hidden tabs, and picking one brings it in front and into the bar. + await page.locator('[data-tab-more]').click() + const rows = page.locator('[data-tab-overflow] [data-overflow-row]') + ok(await until(async () => (await rows.count()) === a.more, 3000, 50), `the list holds the ${a.more} hidden tabs (${await rows.count()})`) + ok(await page.evaluate(() => document.activeElement?.hasAttribute('data-overflow-find')), 'its Find field has the keyboard') + await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/strip-many-list.png') }).catch(() => {}) + const pickName = (await rows.first().textContent()).trim().slice(0, 6) + await rows.first().click() + ok(await until(async () => (await page.locator('[data-tab-overflow]').count()) === 0, 2000, 50), 'picking a row closes the list') + const b = await look() + ok((b.active ?? '').startsWith(pickName) && b.activeNamed, `and its tab is in front, named, in the bar (${b.active}, wanted ${pickName})`) + ok(b.drawn + b.more === 16 && b.scrolls === false, `still 16 tabs, still no scroll (${b.drawn} + ${b.more})`) + + // Find a tab in this layout: the list with every tab, filtered as you type. + await page.locator('.xterm').first().click({ force: true }) + await page.keyboard.press('Control+Shift+K') + ok(await until(async () => (await rows.count()) === 16, 3000, 50), `Ctrl+Shift+K lists every tab (${await rows.count()})`) + await page.keyboard.type('proj03') + ok(await until(async () => (await rows.count()) === 1, 2000, 50), `typing a name keeps its row (${await rows.count()})`) + await page.keyboard.press('Enter') + const c = await look() + ok((c.active ?? '').startsWith('proj03'), `Enter puts it in front (${c.active})`) + await page.keyboard.press('Control+Shift+K') + await until(async () => (await rows.count()) > 0, 2000, 50) + await page.keyboard.press('Escape') + ok(await until(async () => (await page.locator('[data-tab-overflow]').count()) === 0, 2000, 50), 'Escape closes it') + } finally { + await closeApp(app) + } + }, + /** * NO WINDOWS SYSTEM MENU ON A RIGHT-CLICK (owner, 2026-10-13: "stop chromium * right click menus like this from appearing in the terminal"). Windows