From 91b7f4f009befa91507c0e327f2d90afe3c46c64 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 18 Sep 2026 22:10:22 -0700 Subject: [PATCH] Terminal sessions move from the rail into a tab strip on the terminal page The rail is now a flat list of pages; Terminal is one of them. Co-Authored-By: Claude Fable 5.1 --- PLAN.md | 22 +++++++-------- web/src/Rail.tsx | 65 +++++++++++--------------------------------- web/src/Shell.tsx | 27 +++++++++--------- web/src/TermTabs.tsx | 34 +++++++++++++++++++++++ web/src/styles.css | 29 ++++++++++++-------- 5 files changed, 92 insertions(+), 85 deletions(-) create mode 100644 web/src/TermTabs.tsx diff --git a/PLAN.md b/PLAN.md index 75e59d1..0b2f357 100644 --- a/PLAN.md +++ b/PLAN.md @@ -25,22 +25,22 @@ stub tab or a roadmap item. ┌──────────────────────────────────────────────────────────────┐ │ header: Ham Lab · core · load 0.42 · ● connected · [logout] │ ├──────────────┬───────────────────────────────────────────────┤ -│ ▾ Terminal │ │ -│ shell 1 ● │ │ -│ backups │ main content (xterm) │ -│ + New │ │ -│ ▸ Services │ │ -│ ▸ Cron │ │ -│ ▸ ZFS │ │ -│ ▸ Dashboard │ │ +│ Dashboard │ shell 1 ● │ backups │ + │ +│ Processes ├───────────────────────────────────────────────┤ +│ Terminal │ │ +│ Services │ main content (xterm) │ +│ Cron │ │ +│ … │ │ ├──────────────┴───────────────────────────────────────────────┤ │ footer: v0.1.0 · up 12d · hamlab.bigham.dev │ └──────────────────────────────────────────────────────────────┘ ``` -- Left rail: collapsible groups, one expanded at a time. Active leaf highlighted. -- Terminal group: one leaf per session, `● ` when a process is running, "+ New - Terminal" pinned at the bottom. Right-click / long-press → rename, close. +- Left rail: flat list of pages, active one highlighted. No nested leaves. +- Terminal page: tab strip across the top of the pane, one tab per session, `● ` when + a process is running, `+` pinned at the right edge. Active tab carries rename and + close; double-click renames, middle-click closes. (Until 2026-09-18 the sessions + were leaves under a collapsible Terminal group in the rail.) - Non-MVP groups render as disabled leaves with "coming soon" — the rail shape is the product; stubs keep it honest about scope. - **Mobile**: rail becomes a slide-in drawer; xterm fits the viewport; a sticky key diff --git a/web/src/Rail.tsx b/web/src/Rail.tsx index e439ce5..15e92e4 100644 --- a/web/src/Rail.tsx +++ b/web/src/Rail.tsx @@ -1,58 +1,25 @@ -import { ReactNode, useState } from 'react' -import { Term } from './api' -import { Spinner } from './ui' +import { ReactNode } from 'react' -// A group is either a page (clicking the head opens it — Dashboard) or a folder of -// leaves (Terminal). `soon` groups are inert placeholders for the roadmap. -export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean; page?: boolean } +// Every group is a page: clicking it opens it. `soon` groups are inert placeholders +// for the roadmap. +export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean } -interface Props { - groups: Group[]; terms: Term[]; active: string | null; view: string; creating?: boolean; header?: ReactNode - onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void - onRename: (t: Term) => void; onClose: (t: Term) => void -} +interface Props { groups: Group[]; view: string; header?: ReactNode; onView: (id: string) => void } -export default function Rail({ groups, terms, active, view, creating, header, onSelect, onView, onNew, onRename, onClose }: Props) { - const [open, setOpen] = useState('terminal') +export default function Rail({ groups, view, header, onView }: Props) { return ( ) } diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index afe0263..777053d 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api, Me, Term } from './api' import Rail, { Group } from './Rail' import TerminalPane, { TermHandle } from './TerminalPane' +import TermTabs from './TermTabs' import KeyBar from './KeyBar' import Dashboard from './Dashboard' import Services from './Services' @@ -26,16 +27,16 @@ const FolderIcon = ( ) const GROUPS: Group[] = [ - { id: 'dashboard', label: 'Dashboard', icon: '◔', page: true }, - { id: 'procs', label: 'Processes', icon: '≡', page: true }, + { id: 'dashboard', label: 'Dashboard', icon: '◔' }, + { id: 'procs', label: 'Processes', icon: '≡' }, { id: 'terminal', label: 'Terminal', icon: '>_' }, - { id: 'services', label: 'Services', icon: '◈', page: true }, - { id: 'cron', label: 'Cron', icon: '⏱', page: true }, - { id: 'updates', label: 'Updates', icon: '⇪', page: true }, - { id: 'disks', label: 'Disks', icon: '▤', page: true }, - { id: 'files', label: 'Files', icon: FolderIcon, page: true }, - { id: 'machines', label: 'Machines', icon: '⌂', page: true }, - { id: 'settings', label: 'Settings', icon: '⚙', page: true }, + { id: 'services', label: 'Services', icon: '◈' }, + { id: 'cron', label: 'Cron', icon: '⏱' }, + { id: 'updates', label: 'Updates', icon: '⇪' }, + { id: 'disks', label: 'Disks', icon: '▤' }, + { id: 'files', label: 'Files', icon: FolderIcon }, + { id: 'machines', label: 'Machines', icon: '⌂' }, + { id: 'settings', label: 'Settings', icon: '⚙' }, ] type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' @@ -167,11 +168,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
- { setView('machines'); setDrawer(false) }} />} - onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }} - onView={(id) => { setView(id as View); setDrawer(false) }} - onNew={create} onRename={rename} onClose={close} /> + onView={(id) => { setView(id as View); setDrawer(false) }} />
setDrawer(false)} />
@@ -184,6 +183,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'updates' && } {view === 'disks' && } {view === 'files' && } + {termView && } {termView && terms.length === 0 && (
No terminals.
diff --git a/web/src/TermTabs.tsx b/web/src/TermTabs.tsx new file mode 100644 index 0000000..08c28da --- /dev/null +++ b/web/src/TermTabs.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react' +import { Term } from './api' +import { Spinner } from './ui' + +interface Props { + terms: Term[]; active: string | null; creating?: boolean + onSelect: (id: string) => void; onNew: () => void + onRename: (t: Term) => void; onClose: (t: Term) => void +} + +// Tab strip across the top of the terminal page: one tab per session, "+" at the end. +export default function TermTabs({ terms, active, creating, onSelect, onNew, onRename, onClose }: Props) { + const strip = useRef(null) + // keep the active tab in sight when the strip overflows + useEffect(() => { strip.current?.querySelector('.ttab.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [active, terms.length]) + return ( +
e.preventDefault()}> + {terms.map((t) => { + const sel = t.id === active + return ( +
onSelect(t.id)} onDoubleClick={() => onRename(t)} + onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t) } }}> + + {t.name} + {sel && } + +
+ ) + })} + +
+ ) +} diff --git a/web/src/styles.css b/web/src/styles.css index 2b55988..a539c90 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -4,7 +4,7 @@ --ok: #3fb950; --bad: #ff7b72; --s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18); --btn-fg: #111; --term-bg: var(--term-bg); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; - --header-h: 44px; --footer-h: 28px; --rail-w: 220px; + --header-h: 44px; --footer-h: 28px; --rail-w: 220px; --tabs-h: 34px; } * { box-sizing: border-box; } html, body, #root { height: 100%; margin: 0; } @@ -46,20 +46,22 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .chev { width: 10px; color: var(--muted); font-size: 11px; } .gicon { width: 18px; text-align: center; color: var(--accent2); font-family: ui-monospace, monospace; font-size: 12px; display: inline-flex; justify-content: center; align-items: center; } .badge { margin-left: auto; font-size: 10px; text-transform: uppercase; border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; } -.leaves { padding: 2px 0 6px; } -.leaf { display: flex; align-items: center; gap: 8px; padding: 7px 12px 7px 30px; cursor: pointer; width: 100%; text-align: left; } -.leaf:hover { background: var(--bg3); } -.leaf.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); } -.leaf.exited .lname { color: var(--muted); text-decoration: line-through; } -.leaf.new { color: var(--accent2); } -.lname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.leaf-actions { display: flex; gap: 2px; } .sdot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex-shrink: 0; } .sdot.run { background: var(--ok); } /* main + terminal */ .main { flex: 1; min-width: 0; position: relative; background: var(--term-bg); } -.term { position: absolute; inset: 0; padding: 6px 0 0 6px; } +.term { position: absolute; inset: var(--tabs-h) 0 0 0; padding: 6px 0 0 6px; } +.term-tabs { position: absolute; top: 0; left: 0; right: 0; height: var(--tabs-h); display: flex; align-items: stretch; background: var(--bg2); border-bottom: 1px solid var(--border); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; -webkit-overflow-scrolling: touch; } +.term-tabs::-webkit-scrollbar { display: none; } +.ttab { flex: 0 1 200px; min-width: 96px; display: flex; align-items: center; gap: 6px; padding: 0 4px 0 10px; cursor: pointer; border-right: 1px solid var(--border); color: var(--muted); font-size: 13px; user-select: none; } +.ttab:hover { background: var(--bg3); color: var(--fg); } +.ttab.active { background: var(--term-bg); color: var(--fg); box-shadow: inset 0 2px 0 var(--accent); } +.ttab.exited .tname { text-decoration: line-through; } +.ttab .icon-btn { padding: 0 5px; font-size: 12px; } +.ttab:not(.active):not(:hover) .icon-btn { visibility: hidden; } +.ttab.new { flex: 0 0 auto; min-width: 40px; justify-content: center; padding: 0 12px; border-right: 0; color: var(--accent2); font-size: 16px; position: sticky; right: 0; background: var(--bg2); box-shadow: -1px 0 0 var(--border); } +.tname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .term-inner { height: 100%; } .term .xterm { height: 100%; } .xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); } @@ -79,6 +81,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .drawer-open .rail { transform: none; } .drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); } .footer { font-size: 11px; } + .ttab { flex: 0 0 auto; max-width: 70vw; } } /* dashboard */ @@ -369,7 +372,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); /* per-theme flourishes */ [data-theme="cyberpunk"] .brand, [data-theme="cyberpunk"] .login h1 { text-shadow: 0 0 8px rgba(255, 43, 214, .7), 0 0 18px rgba(255, 43, 214, .35); } [data-theme="cyberpunk"] .gicon, [data-theme="cyberpunk"] .box-title { text-shadow: 0 0 6px rgba(0, 229, 255, .6); } -[data-theme="cyberpunk"] .leaf.active, [data-theme="cyberpunk"] .group-head.active { box-shadow: inset 3px 0 0 var(--accent), 0 0 12px rgba(255, 43, 214, .25); } +[data-theme="cyberpunk"] .group-head.active { box-shadow: inset 3px 0 0 var(--accent), 0 0 12px rgba(255, 43, 214, .25); } [data-theme="retro"] body, [data-theme="retro"] .btn, [data-theme="retro"] input, [data-theme="retro"] select { font-family: var(--mono); } [data-theme="retro"] .brand, [data-theme="retro"] .login h1 { text-shadow: 0 0 6px rgba(255, 176, 0, .6); letter-spacing: 1px; } [data-theme="retro"] .app::after { content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 100; background: repeating-linear-gradient(0deg, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.12) 2px 3px); } @@ -384,7 +387,9 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); @media (pointer: coarse) { .btn, .chip, .icon-btn, .link, .th, .mod-tools button, .hide-item, .crumb, .files-tools select, .procs-tools select, .dash-bar select { min-height: 36px; padding-top: 6px; padding-bottom: 6px; } .icon-btn { min-width: 36px; } - .leaf-actions .icon-btn { min-width: 34px; } + :root { --tabs-h: 40px; } + .ttab .icon-btn { min-width: 30px; } + .ttab:not(.active) .icon-btn { display: none; } .header .link, .header .icon-btn { min-height: 36px; display: inline-flex; align-items: center; } .mod-tools { margin-top: 0; } }