Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions docs/superpowers/specs/2026-10-15-many-tabs-design.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions src/renderer/src/components/TabOverflowList.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const field = useRef<HTMLInputElement>(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 => (
<button
key={r.id}
role="option"
aria-selected={false}
data-overflow-row={r.id}
className="flex h-[34px] w-full items-center gap-2.5 rounded-[3px] px-2.5 text-left text-[12.5px] text-[var(--p-text-soft)] transition-colors hover:bg-[var(--p-hover)] hover:text-[var(--p-text)] focus-visible:bg-[var(--p-hover)] focus-visible:outline-none"
onClick={() => {
onPick(r.id)
onClose()
}}
onContextMenu={(e) => {
e.preventDefault()
onRowMenu(e, r.id)
}}
>
{r.icon}
<span className="min-w-0 flex-1 truncate">
{r.label}
<span className="ml-2 text-[11px] text-[var(--p-dim2)]">{folderLine(r.cwd, home)}</span>
</span>
{r.mark && <span className="grid w-4 shrink-0 place-items-center">{r.mark}</span>}
</button>
)
const head = (text: string): JSX.Element => (
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-[.06em] text-[var(--p-dim2)]">{text}</div>
)

return createPortal(
<div
ref={box}
data-tab-overflow
className={`no-drag fixed ${LAYER.menu} flex max-h-[min(440px,70vh)] w-[340px] flex-col overflow-hidden rounded-[4px] border border-[color:var(--p-divider)] bg-[var(--p-side-flat)] shadow-[0_10px_28px_rgba(0,0,0,.5)]`}
style={{ left: pos.x, top: pos.y }}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}}
>
<div className="flex items-center gap-2 border-b border-[color:var(--p-divider)] px-3 py-2 text-[var(--p-dim2)]">
<svg viewBox="0 0 24 24" width={13} height={13} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-4.5-4.5" />
</svg>
<input
ref={field}
role="combobox"
aria-expanded
aria-controls="tab-overflow-rows"
aria-label="Find a tab"
data-overflow-find
placeholder="Find a tab"
value={query}
onChange={(e) => 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)]"
/>
</div>
<div id="tab-overflow-rows" role="listbox" aria-label="Tabs" className="min-h-0 flex-1 overflow-y-auto p-1">
{needs.length > 0 && head('Needs you')}
{needs.map(row)}
{rest.length > 0 && head(needs.length ? 'Other tabs' : 'Tabs')}
{rest.map(row)}
{!kept.length && <div className="px-2.5 py-3 text-[12px] text-[var(--p-dim2)]">No tab has that name or folder.</div>}
</div>
</div>,
document.body
)
}
Loading
Loading