Replace each card's "+ start claude here" with a row of three brand-logo launcher buttons (logo + caption, touch-friendly, tooltips): - Claude (coral burst) → new terminal tab running `claude` - Codex (OpenAI mark) → new terminal tab running `codex` - VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default `code`) on the host via execFile (no shell); path validated as an existing absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added. Active-session highlight (user request): when a project has a running Claude session (Claude-hook status != unknown — plain shells/codex stay unknown), the Claude logo gets a coral ring + tint, with a count badge when more than one. New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts. onOpenProject gains an optional cmd; makeProjectCard exported for tests. Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher row (3 buttons, claude/codex commands), and the active-Claude highlight + badge. Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3 logos render with brand colours, and POST /open-in-editor actually launched VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
610 lines
22 KiB
TypeScript
610 lines
22 KiB
TypeScript
/**
|
||
* public/tabs.ts — multi-tab manager + tab bar UI.
|
||
*
|
||
* Each tab owns an independent TerminalSession (own WS + server session).
|
||
*
|
||
* Titles: auto from the terminal (current folder, via folderFromTitle) →
|
||
* double-click to rename inline → falls back to "Term N". Manual wins.
|
||
* Interaction: pointerdown to switch (robust with draggable), drag to reorder,
|
||
* middle-click / × to close, status dot (connection color), unread dot.
|
||
*
|
||
* DOM is built once per tab and updated in place. A full rebuild happens ONLY
|
||
* on structural changes (add / close / reorder / rename) — never on a plain
|
||
* activate or on background output — so clicks are never eaten by a re-render.
|
||
*
|
||
* Closing a tab only disconnects its WS (a *detach*); the server PTY keeps
|
||
* running and is reclaimed by IDLE_TTL.
|
||
*
|
||
* v0.6 (P6): the home screen has a segmented control ("Sessions" / "Projects")
|
||
* that switches between the session launcher and the projects panel. State is
|
||
* persisted to localStorage. openProject() spawns a new claude session in a
|
||
* repo directory, labelled with the repo name (deduped with #N suffixes).
|
||
*/
|
||
|
||
import { TerminalSession } from './terminal-session.js'
|
||
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
||
import { mountLauncher, type Launcher } from './launcher.js'
|
||
import { mountProjects, type ProjectsPanel } from './projects.js'
|
||
|
||
const TABS_KEY = 'web-terminal:tabs'
|
||
const ACTIVE_KEY = 'web-terminal:active'
|
||
const HOME_VIEW_KEY = 'web-terminal:home-view'
|
||
|
||
type HomeView = 'sessions' | 'projects'
|
||
|
||
interface TabEntry {
|
||
session: TerminalSession
|
||
customTitle: string | null // user-set; null = use auto/fallback (persisted)
|
||
autoTitle: string | null // current folder from the terminal title
|
||
hasActivity: boolean // inactive tab got output since last viewed
|
||
el: HTMLDivElement | null // the .tab element (updated in place)
|
||
}
|
||
|
||
interface StoredTab {
|
||
id: string | null
|
||
title: string | null
|
||
}
|
||
|
||
export class TabApp {
|
||
private readonly tabs: TabEntry[] = []
|
||
private activeIndex = -1
|
||
private editingIndex = -1
|
||
private dragIndex = -1
|
||
private notifyAsked = false
|
||
private settings: Settings = DEFAULT_SETTINGS
|
||
private readonly paneHost: HTMLElement
|
||
private readonly tabBar: HTMLElement
|
||
private readonly approvalBar: HTMLDivElement
|
||
private readonly launcher: Launcher
|
||
private readonly projects: ProjectsPanel
|
||
private homeView: HomeView = 'sessions'
|
||
private readonly segControl: HTMLElement
|
||
// When true, the home chooser is overlaid on top of the open tabs (so you can
|
||
// jump back to Sessions/Projects without closing anything). Reset whenever a
|
||
// tab is activated. Irrelevant while no tab is open (home shows regardless).
|
||
private homeForced = false
|
||
private homeBtn: HTMLButtonElement | null = null
|
||
|
||
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
||
this.paneHost = paneHost
|
||
this.tabBar = tabBar
|
||
this.approvalBar = document.createElement('div')
|
||
this.approvalBar.id = 'approvalbar'
|
||
this.approvalBar.style.display = 'none'
|
||
document.body.appendChild(this.approvalBar)
|
||
|
||
this.launcher = mountLauncher(this.paneHost, {
|
||
onOpen: (id) => this.openSession(id),
|
||
onNew: () => this.newTab(),
|
||
})
|
||
|
||
// Load persisted home view choice.
|
||
try {
|
||
const stored = localStorage.getItem(HOME_VIEW_KEY)
|
||
if (stored === 'sessions' || stored === 'projects') this.homeView = stored
|
||
} catch {
|
||
// localStorage unavailable — use default
|
||
}
|
||
|
||
this.projects = mountProjects(this.paneHost, {
|
||
onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd),
|
||
onEnterSession: (id) => this.openSession(id),
|
||
})
|
||
|
||
this.segControl = this.buildSegControl()
|
||
this.paneHost.appendChild(this.segControl)
|
||
|
||
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
|
||
// the user picks which session to open. Sessions persist server-side, so
|
||
// opening one replays its full scrollback.
|
||
this.rebuild()
|
||
this.updateHomeView()
|
||
}
|
||
|
||
/* ── Home-view visibility ────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Controls visibility of the segmented control and both home sub-panels.
|
||
* When tabs are open: hide everything (the terminal panes own the screen).
|
||
* When no tab is open: show the seg control and the selected sub-panel.
|
||
*/
|
||
private updateHomeView(): void {
|
||
// Home shows when there is no tab OR the user explicitly forced it (the ⌂
|
||
// button), letting you reach Sessions/Projects without closing a session.
|
||
const showHome = this.tabs.length === 0 || this.homeForced
|
||
|
||
// Sync active-class on segmented control buttons.
|
||
const btns = this.segControl.querySelectorAll<HTMLButtonElement>('.home-seg-btn')
|
||
btns.forEach((btn, i) => {
|
||
const view: HomeView = i === 0 ? 'sessions' : 'projects'
|
||
btn.classList.toggle('active', view === this.homeView)
|
||
})
|
||
// The ⌂ button is "pressed" only while overlaying home on top of open tabs.
|
||
this.homeBtn?.classList.toggle('active', this.homeForced && this.tabs.length > 0)
|
||
|
||
// Hide the bottom key-bar on the home chooser — it only sends keys to an
|
||
// active terminal, so it's noise on the Sessions/Projects views.
|
||
document.body.classList.toggle('home-open', showHome)
|
||
|
||
if (showHome) {
|
||
// Overlay home: hide every terminal pane so the chooser is on top.
|
||
for (const t of this.tabs) t.session.hide()
|
||
this.segControl.style.display = 'flex'
|
||
this.launcher.setVisible(this.homeView === 'sessions')
|
||
this.projects.setVisible(this.homeView === 'projects')
|
||
} else {
|
||
this.segControl.style.display = 'none'
|
||
this.launcher.setVisible(false)
|
||
this.projects.setVisible(false)
|
||
}
|
||
}
|
||
|
||
/** Toggle the home chooser overlay over the open tabs (the ⌂ button). No-op
|
||
* when no tab is open (home is already showing). */
|
||
private toggleHome(): void {
|
||
if (this.tabs.length === 0) return
|
||
this.homeForced = !this.homeForced
|
||
if (this.homeForced) {
|
||
this.updateHomeView()
|
||
} else {
|
||
// Return to the active terminal (activate re-shows its pane + hides home).
|
||
this.activate(this.activeIndex)
|
||
}
|
||
}
|
||
|
||
private saveHomeView(): void {
|
||
try {
|
||
localStorage.setItem(HOME_VIEW_KEY, this.homeView)
|
||
} catch {
|
||
// localStorage unavailable
|
||
}
|
||
}
|
||
|
||
private buildSegControl(): HTMLElement {
|
||
const seg = document.createElement('div')
|
||
seg.className = 'home-seg'
|
||
seg.style.display = 'none' // updateHomeView() will show it when needed
|
||
|
||
const sessBtn = document.createElement('button')
|
||
sessBtn.className = 'home-seg-btn'
|
||
sessBtn.textContent = 'Sessions'
|
||
sessBtn.addEventListener('click', () => {
|
||
this.homeView = 'sessions'
|
||
this.saveHomeView()
|
||
this.updateHomeView()
|
||
})
|
||
|
||
const projBtn = document.createElement('button')
|
||
projBtn.className = 'home-seg-btn'
|
||
projBtn.textContent = 'Projects'
|
||
projBtn.addEventListener('click', () => {
|
||
this.homeView = 'projects'
|
||
this.saveHomeView()
|
||
this.updateHomeView()
|
||
})
|
||
|
||
seg.append(sessBtn, projBtn)
|
||
return seg
|
||
}
|
||
|
||
/* ── Approval bar ───────────────────────────────────────────────── */
|
||
|
||
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
|
||
private updateApprovalBar(): void {
|
||
const session = this.tabs[this.activeIndex]?.session
|
||
if (!session || !session.pendingApproval) {
|
||
this.approvalBar.style.display = 'none'
|
||
return
|
||
}
|
||
this.approvalBar.replaceChildren()
|
||
const label = document.createElement('span')
|
||
label.className = 'approval-label'
|
||
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
|
||
const approve = document.createElement('button')
|
||
approve.className = 'approval-yes'
|
||
approve.textContent = '✓ Approve'
|
||
approve.addEventListener('click', () => {
|
||
session.approve()
|
||
this.updateApprovalBar()
|
||
})
|
||
const reject = document.createElement('button')
|
||
reject.className = 'approval-no'
|
||
reject.textContent = '✗ Reject'
|
||
reject.addEventListener('click', () => {
|
||
session.reject()
|
||
this.updateApprovalBar()
|
||
})
|
||
this.approvalBar.append(label, approve, reject)
|
||
this.approvalBar.style.display = 'flex'
|
||
}
|
||
|
||
private displayTitle(entry: TabEntry, idx: number): string {
|
||
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
|
||
}
|
||
|
||
/* ── persistence ─────────────────────────────────────────────────── */
|
||
|
||
private persist(): void {
|
||
try {
|
||
const stored: StoredTab[] = this.tabs.map((t) => ({ id: t.session.id, title: t.customTitle }))
|
||
localStorage.setItem(TABS_KEY, JSON.stringify(stored))
|
||
localStorage.setItem(ACTIVE_KEY, String(this.activeIndex))
|
||
} catch {
|
||
// localStorage unavailable — run without persistence
|
||
}
|
||
}
|
||
|
||
/** The active tab's server session id (null until it has attached). */
|
||
activeSessionId(): string | null {
|
||
return this.tabs[this.activeIndex]?.session.id ?? null
|
||
}
|
||
|
||
/** Focus the tab for `sessionId`, or open one that joins it (share-link target). */
|
||
openSession(sessionId: string): void {
|
||
const existing = this.tabs.findIndex((t) => t.session.id === sessionId)
|
||
if (existing >= 0) {
|
||
this.activate(existing)
|
||
return
|
||
}
|
||
this.addEntry(sessionId, null)
|
||
this.persist()
|
||
this.rebuild()
|
||
this.activate(this.tabs.length - 1)
|
||
}
|
||
|
||
/* ── tab lifecycle ───────────────────────────────────────────────── */
|
||
|
||
private addEntry(
|
||
sessionId: string | null,
|
||
customTitle: string | null,
|
||
cwd?: string,
|
||
initialInput?: string,
|
||
): TabEntry {
|
||
// Build the session FIRST so `entry` is never typed with a null session.
|
||
// The callbacks below capture `entry` by reference; they only fire after
|
||
// construction (async), by which point `entry` is assigned.
|
||
let entry: TabEntry
|
||
const session = new TerminalSession({
|
||
sessionId,
|
||
...(cwd !== undefined ? { cwd } : {}),
|
||
...(initialInput !== undefined ? { initialInput } : {}),
|
||
onSessionId: () => this.persist(),
|
||
// onActivity only fires for hidden (inactive) panes (see TerminalSession).
|
||
onActivity: () => {
|
||
entry.hasActivity = true
|
||
this.refreshTab(entry)
|
||
},
|
||
onTitle: (title) => {
|
||
entry.autoTitle = title.trim() || null
|
||
this.refreshTab(entry)
|
||
},
|
||
onStatus: () => this.refreshTab(entry),
|
||
onClaudeStatus: (status) => {
|
||
this.refreshTab(entry)
|
||
this.updateApprovalBar()
|
||
// Notify when a background tab needs approval (H2/H4).
|
||
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
|
||
this.notify(entry)
|
||
}
|
||
},
|
||
})
|
||
entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null }
|
||
this.paneHost.appendChild(session.el)
|
||
this.tabs.push(entry)
|
||
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
|
||
session.connect()
|
||
return entry
|
||
}
|
||
|
||
newTab(): void {
|
||
// M6: open the new tab in the active tab's current directory, if known.
|
||
const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined
|
||
this.addEntry(null, null, cwd)
|
||
this.persist()
|
||
this.rebuild()
|
||
this.activate(this.tabs.length - 1)
|
||
}
|
||
|
||
/** O2: open a new tab in `cwd` and run `claude --resume <id>`. */
|
||
newTabForResume(cwd: string, sessionId: string): void {
|
||
this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}\r`)
|
||
this.persist()
|
||
this.rebuild()
|
||
this.activate(this.tabs.length - 1)
|
||
}
|
||
|
||
/**
|
||
* v0.6 (P6): spawn a new claude session in `repoPath`, labelled `repoName`.
|
||
* If tabs with the same base name already exist, appends a #N suffix to
|
||
* distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
|
||
* structure: addEntry → persist → rebuild → activate.
|
||
*/
|
||
openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void {
|
||
const n = this.countOpenWithTitlePrefix(repoName)
|
||
const label = n === 0 ? repoName : `${repoName} #${n + 1}`
|
||
this.addEntry(null, label, repoPath || undefined, cmd)
|
||
this.persist()
|
||
this.rebuild()
|
||
this.activate(this.tabs.length - 1)
|
||
}
|
||
|
||
/** Count tabs whose customTitle is exactly `name` or starts with `name + ' #'`. */
|
||
private countOpenWithTitlePrefix(name: string): number {
|
||
return this.tabs.filter(
|
||
(t) => t.customTitle === name || t.customTitle?.startsWith(`${name} #`) === true,
|
||
).length
|
||
}
|
||
|
||
activate(i: number): void {
|
||
if (i < 0 || i >= this.tabs.length) return
|
||
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
||
this.homeForced = false // showing a terminal dismisses any home overlay
|
||
this.activeIndex = i
|
||
const entry = this.tabs[i]
|
||
if (entry) entry.hasActivity = false // viewing clears the unread dot
|
||
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
|
||
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
|
||
this.updateHomeView() // hide the seg control + home panels now a tab is active
|
||
this.updateApprovalBar()
|
||
this.persist()
|
||
}
|
||
|
||
closeTab(i: number): void {
|
||
if (i < 0 || i >= this.tabs.length) return
|
||
const [entry] = this.tabs.splice(i, 1)
|
||
entry?.session.dispose()
|
||
if (this.editingIndex === i) this.editingIndex = -1
|
||
if (this.tabs.length === 0) {
|
||
// v0.5: closing the last tab returns to the home screen — no auto-blank tab.
|
||
this.activeIndex = -1
|
||
this.homeForced = false // back to the natural "no tabs → home" state
|
||
this.persist()
|
||
this.rebuild() // updateHomeView() (in rebuild) shows the chooser
|
||
this.updateApprovalBar()
|
||
return
|
||
}
|
||
const next = Math.min(i, this.tabs.length - 1)
|
||
this.persist()
|
||
this.rebuild()
|
||
this.activate(next)
|
||
}
|
||
|
||
private moveTab(from: number, to: number): void {
|
||
if (from < 0 || to < 0 || from === to || from >= this.tabs.length || to >= this.tabs.length) {
|
||
return
|
||
}
|
||
const activeEntry = this.tabs[this.activeIndex]
|
||
const [moved] = this.tabs.splice(from, 1)
|
||
if (!moved) return
|
||
this.tabs.splice(to, 0, moved)
|
||
if (activeEntry) this.activeIndex = this.tabs.indexOf(activeEntry)
|
||
this.persist()
|
||
this.rebuild()
|
||
}
|
||
|
||
private commitRename(i: number, value: string): void {
|
||
const entry = this.tabs[i]
|
||
if (entry) {
|
||
const trimmed = value.trim()
|
||
entry.customTitle = trimmed.length > 0 ? trimmed : null // empty → revert to auto
|
||
}
|
||
this.editingIndex = -1
|
||
this.persist()
|
||
this.rebuild()
|
||
}
|
||
|
||
/** Route key-bar input to the currently active tab. */
|
||
sendToActive(data: string): void {
|
||
this.tabs[this.activeIndex]?.session.send(data)
|
||
}
|
||
|
||
/** Re-assert the active tab's size (latest-writer-wins) when this device
|
||
* regains focus, so a shared session snaps back to this screen's size. */
|
||
refitActive(): void {
|
||
this.tabs[this.activeIndex]?.session.refit()
|
||
}
|
||
|
||
/** Apply theme + font settings to every terminal (M3). */
|
||
applySettings(s: Settings): void {
|
||
this.settings = s
|
||
const theme = THEMES[s.theme] ?? THEMES['dark']!
|
||
for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize)
|
||
}
|
||
|
||
/** A read-only view of all tabs for the dashboard (M7). */
|
||
snapshot(): Array<{
|
||
idx: number
|
||
title: string
|
||
conn: string
|
||
claude: string
|
||
active: boolean
|
||
pending: boolean
|
||
}> {
|
||
return this.tabs.map((t, idx) => ({
|
||
idx,
|
||
title: this.displayTitle(t, idx),
|
||
conn: t.session.status,
|
||
claude: t.session.claudeStatus,
|
||
active: idx === this.activeIndex,
|
||
pending: t.session.pendingApproval,
|
||
}))
|
||
}
|
||
|
||
focusTab(idx: number): void {
|
||
this.activate(idx)
|
||
}
|
||
|
||
/** Scrollback search in the active tab (M1). */
|
||
findInActive(query: string, dir: 'next' | 'prev'): void {
|
||
const s = this.tabs[this.activeIndex]?.session
|
||
if (!s) return
|
||
if (dir === 'next') s.findNext(query)
|
||
else s.findPrevious(query)
|
||
}
|
||
|
||
clearActiveSearch(): void {
|
||
this.tabs[this.activeIndex]?.session.clearSearch()
|
||
}
|
||
|
||
/** Ask for notification permission once, on a user gesture (H2/H4). */
|
||
private maybeAskNotify(): void {
|
||
if (this.notifyAsked) return
|
||
this.notifyAsked = true
|
||
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
||
void Notification.requestPermission()
|
||
}
|
||
}
|
||
|
||
/** Browser notification for a background tab needing approval. */
|
||
private notify(entry: TabEntry): void {
|
||
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return
|
||
const title = this.displayTitle(entry, this.tabs.indexOf(entry))
|
||
new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
|
||
}
|
||
|
||
/* ── rendering ───────────────────────────────────────────────────── */
|
||
|
||
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */
|
||
private refreshTab(entry: TabEntry): void {
|
||
const el = entry.el
|
||
if (!el) return
|
||
const idx = this.tabs.indexOf(entry)
|
||
el.classList.toggle('active', idx === this.activeIndex)
|
||
el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex)
|
||
const cs = entry.session.claudeStatus
|
||
el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex)
|
||
const title = this.displayTitle(entry, idx)
|
||
el.title = title
|
||
const dot = el.querySelector('.tab-dot')
|
||
if (dot) dot.className = `tab-dot dot-${entry.session.status}`
|
||
const label = el.querySelector('.tab-label')
|
||
if (label) label.textContent = title
|
||
const claude = el.querySelector('.tab-claude')
|
||
if (claude) {
|
||
claude.textContent =
|
||
cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : ''
|
||
}
|
||
}
|
||
|
||
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
|
||
private rebuild(): void {
|
||
this.tabBar.replaceChildren()
|
||
|
||
// Home (⌂): overlay the Sessions/Projects chooser over the open tabs so you
|
||
// can start another session/project without closing the current one.
|
||
const home = document.createElement('button')
|
||
home.className = 'tab-home'
|
||
home.textContent = '⌂'
|
||
home.title = 'Home — Sessions & Projects'
|
||
home.setAttribute('aria-label', 'Home — Sessions & Projects')
|
||
home.addEventListener('click', () => this.toggleHome())
|
||
this.tabBar.appendChild(home)
|
||
this.homeBtn = home
|
||
|
||
this.tabs.forEach((entry, idx) => {
|
||
const tabEl = document.createElement('div')
|
||
entry.el = tabEl
|
||
tabEl.className = 'tab'
|
||
|
||
// Switch on pointerdown (fires before any drag; reliable with draggable, works on touch).
|
||
tabEl.addEventListener('pointerdown', () => this.activate(idx))
|
||
// Middle-click closes.
|
||
tabEl.addEventListener('auxclick', (e) => {
|
||
if (e.button === 1) {
|
||
e.preventDefault()
|
||
this.closeTab(idx)
|
||
}
|
||
})
|
||
|
||
// Drag-to-reorder (disabled while renaming).
|
||
if (idx !== this.editingIndex) {
|
||
tabEl.draggable = true
|
||
tabEl.addEventListener('dragstart', (e) => {
|
||
this.dragIndex = idx
|
||
e.dataTransfer?.setData('text/plain', String(idx))
|
||
tabEl.classList.add('dragging')
|
||
})
|
||
tabEl.addEventListener('dragover', (e) => {
|
||
e.preventDefault()
|
||
tabEl.classList.add('drag-over')
|
||
})
|
||
tabEl.addEventListener('dragleave', () => tabEl.classList.remove('drag-over'))
|
||
tabEl.addEventListener('drop', (e) => {
|
||
e.preventDefault()
|
||
tabEl.classList.remove('drag-over')
|
||
this.moveTab(this.dragIndex, idx)
|
||
})
|
||
tabEl.addEventListener('dragend', () => {
|
||
this.dragIndex = -1
|
||
tabEl.classList.remove('dragging')
|
||
})
|
||
}
|
||
|
||
const dot = document.createElement('span')
|
||
dot.className = `tab-dot dot-${entry.session.status}`
|
||
tabEl.appendChild(dot)
|
||
|
||
if (idx === this.editingIndex) {
|
||
const input = document.createElement('input')
|
||
input.className = 'tab-rename'
|
||
input.value = this.displayTitle(entry, idx)
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') this.commitRename(idx, input.value)
|
||
else if (e.key === 'Escape') {
|
||
this.editingIndex = -1
|
||
this.rebuild()
|
||
}
|
||
e.stopPropagation()
|
||
})
|
||
input.addEventListener('blur', () => this.commitRename(idx, input.value))
|
||
input.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't re-activate
|
||
tabEl.appendChild(input)
|
||
requestAnimationFrame(() => {
|
||
input.focus()
|
||
input.select()
|
||
})
|
||
} else {
|
||
const label = document.createElement('span')
|
||
label.className = 'tab-label'
|
||
label.textContent = this.displayTitle(entry, idx)
|
||
label.addEventListener('dblclick', () => {
|
||
this.editingIndex = idx
|
||
this.rebuild()
|
||
})
|
||
tabEl.appendChild(label)
|
||
|
||
const claude = document.createElement('span')
|
||
claude.className = 'tab-claude'
|
||
tabEl.appendChild(claude)
|
||
|
||
const close = document.createElement('button')
|
||
close.className = 'tab-close'
|
||
close.textContent = '×'
|
||
close.title = 'Close tab (the shell keeps running on the server)'
|
||
close.setAttribute('aria-label', 'Close tab')
|
||
close.addEventListener('pointerdown', (e) => e.stopPropagation())
|
||
close.addEventListener('click', (e) => {
|
||
e.stopPropagation()
|
||
this.closeTab(idx)
|
||
})
|
||
tabEl.appendChild(close)
|
||
}
|
||
|
||
// Apply current active/unread state.
|
||
this.refreshTab(entry)
|
||
this.tabBar.appendChild(tabEl)
|
||
})
|
||
|
||
const add = document.createElement('button')
|
||
add.className = 'tab-add'
|
||
add.textContent = '+'
|
||
add.title = 'New session'
|
||
add.setAttribute('aria-label', 'New session')
|
||
add.addEventListener('click', () => this.newTab())
|
||
this.tabBar.appendChild(add)
|
||
|
||
// Show/hide the home screen based on whether any tab is open.
|
||
this.updateHomeView()
|
||
}
|
||
}
|