Keep SSH terminal sessions connected across page navigation (#30)

The Terminal page held all session state (xterm instances and their
WebSockets) in component-local React state. Because it renders as a
`<Route element={<Terminal />}>`, navigating away unmounted it and ran
the xterm cleanup (`term.dispose()` + `ws.close()`), tearing down every
SSH session. Returning to the page reconnected from scratch, losing
scrollback and any running work.

Lift terminal sessions into a `TerminalSessionProvider` mounted above the
router (in `main.tsx`, inside `AuthProvider`). The provider owns each
pane's xterm instance, fit addon, WebSocket, and a persistent wrapper DOM
node. Wrappers live in a hidden container at the app root; the Terminal
page re-parents them into its grid on mount and moves them back to the
hidden root on unmount instead of disposing — so the xterm + WebSocket
keep running in the background across route changes.

Disconnect semantics: closing a tab/pane (or shrinking the 1/2/4 grid)
destroys those sessions; logout tears down all sessions. A full browser
reload still drops connections (the WebSocket dies with the page) — this
persists across in-app navigation only.

Shared terminal constants/types/prefs are split into a non-component
module (`src/lib/terminalPrefs.ts`) so the context file stays a clean
component module.

Also document the terminal window grid-view tiering in ROADMAP.md
(self-hosted = 4-window cap, current; paid = as many as fit on screen,
planned for the AWS deployment), and realign HANDOFF/README/design-docs
to reflect that auth Phase 3 (multi-user) shipped and Phase 4 (SSO) is
deferred to a paid AWS add-on.

Verified with a clean `tsc -b && vite build` (frontend) and
`tsc --noEmit -p .` (backend).

Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
This commit is contained in:
Samuel James 2026-06-20 15:02:50 -04:00 committed by GitHub
parent 7f354e54ab
commit b836ac1a02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 624 additions and 267 deletions

View file

@ -6,7 +6,7 @@ Status snapshot as of **2026-06-20**, branch `claude/dazzling-mendel-rzyxos`. Wr
ArchNest is **live and deployed** at `archnest.snsnetlabs.com`, auto-deploying via GitHub Actions (`.github/workflows/deploy.yml`) on every merge to `main` — push triggers a build + SCP + `docker compose up -d --build` on `racknerd1`, with a health-check gate (`/api/health`). Deployment is no longer the open task; it's working infrastructure now.
The current focus is **auth/account features**: the top-right user menu (Profile/Appearance/Security) was fixed from being dead links (Phase 1), then **password management, sessions, and login audit logging shipped (Phase 2)**. The remaining unbuilt scope is **multi-user accounts (Phase 3, in progress) and Authentik SSO (Phase 4)**. See the phase breakdown below.
The current focus is **auth/account features**: the top-right user menu (Profile/Appearance/Security) was fixed from being dead links (Phase 1), then **password management, sessions, and login audit logging shipped (Phase 2)**, then **multi-user accounts with admin/member roles shipped (Phase 3)**. **Phase 4 (Authentik SSO) is deferred to a paid add-on for the future AWS deployment** — see `ROADMAP.md`. With Phases 1-3 done, there is no active auth task in the current self-hosted build.
## Standing rules (read before doing anything)
@ -31,10 +31,10 @@ The current focus is **auth/account features**: the top-right user menu (Profile
### Backend (`/backend`)
- Fastify 5, TypeScript, ESM (`type: "module"``tsx` in dev, entrypoint `src/server.ts`).
- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log, plus `sessions` and `login_events` tables (Phase 2). **Multi-user is in progress (Phase 3)**: a `role`/`active` column is being added to `users`.
- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log, plus `sessions` and `login_events` tables (Phase 2). **Multi-user shipped (Phase 3)**: `users` has `role` (`admin`/`member`) and `active` columns, added via idempotent boot-time migrations.
- `backend/src/db/crypto.ts` — AES-256-GCM `encryptSecret`/`decryptSecret`, keyed by `ARCHNEST_SECRET_KEY`.
- `backend/src/routes/` — one file per route group (`auth`, `bookmarks`, `integrations`, `events`, `terminal`, `tunnels`, `files`, `docker`, `guacamole`, `metrics`, `transfer`, `data`).
- `backend/src/routes/auth.ts``/api/setup` (first-run, creates the first admin user), `/api/auth/login`, `/api/auth/me` (GET/PUT), `/api/auth/password`, `/api/auth/sessions`, `/api/auth/logout`, `/api/auth/login-events` (Phase 2). User-management endpoints land here in Phase 3.
- `backend/src/routes/auth.ts``/api/setup` (first-run, creates the first admin user), `/api/auth/login`, `/api/auth/me` (GET/PUT), `/api/auth/password`, `/api/auth/sessions`, `/api/auth/logout`, `/api/auth/login-events` (Phase 2), plus user-management endpoints `/api/users` (GET/POST) and `/api/users/:id` (PUT/DELETE) gated by `requireAdmin` (Phase 3).
- `backend/src/integrations/` — the 8 integration adapters (Proxmox, Docker, NetBird, Cloudflare, AWS, Uptime Kuma, Weather, SSH).
- `backend/src/ssh/` — SSH-backed feature engines: terminal sessions, tunnels, file ops, host metrics collectors, host-to-host transfer.
- Docker images run on Alpine; **OpenSSL legacy provider is enabled** in `backend/Dockerfile` (`OPENSSL_CONF=/etc/ssl/openssl-legacy.cnf`) so old-format encrypted PEM keys (`BEGIN RSA PRIVATE KEY` + `DEK-Info`) still decrypt under OpenSSL 3 — don't remove this without understanding why it's there.
@ -56,9 +56,9 @@ See `TERMIX_MIGRATION.md` for the phase-by-phase record of the original feature
10. **TopBar global search** — across nav pages, integrations, bookmarks.
11. **Settings UX fixes** — secret fields show a "· saved" indicator instead of appearing blank/deleted after reload (`secretKeys: string[]` on the integration serializer); SSH host cards default-collapsed if already configured; SSH private-key/cert fields support file upload to avoid paste corruption.
## Current initiative: User menu → full auth system (in progress)
## Auth system — Phases 1-3 complete
The user menu (`TopBar.tsx`, avatar dropdown) had `Profile`/`Appearance`/`Security` as dead `href="#"` links. Root-caused and scoped into 4 phases; **Phases 1 and 2 shipped, Phase 3 is in progress**.
The user menu (`TopBar.tsx`, avatar dropdown) had `Profile`/`Appearance`/`Security` as dead `href="#"` links. Root-caused and scoped into 4 phases; **Phases 1, 2, and 3 shipped. Phase 4 (SSO) is deferred to a paid AWS add-on — see `ROADMAP.md`.**
### Phase 1 — DONE (merged, deployed)
- Added `?tab=` deep-linking to `Settings.tsx` (`useSearchParams`) so menu items can jump to a specific section instead of always landing on Profile.
@ -73,27 +73,25 @@ Password change + sessions + login audit log, still single-user. Shipped in PR #
- Endpoints in `auth.ts`: `PUT /api/auth/password` (verify current via bcrypt, hash new at cost 12, revoke all *other* sessions), `GET /api/auth/sessions`, `DELETE /api/auth/sessions/:id` (can't revoke current), `POST /api/auth/logout` (revokes current), `GET /api/auth/login-events?limit`.
- `SecuritySection` in `Settings.tsx` is fully built: change-password form, active-sessions list with per-session "Sign out", recent login-activity feed. `AuthContext.logout()` calls `POST /api/auth/logout` so signing out revokes the server session.
### Phase 3 — IN PROGRESS. Multi-user (cap: 10 seats)
- **Decision already made by the user**: dashboard data (integrations, bookmarks, tunnels, etc.) is **shared across all users**, not private per-user — this is a household/self-hosted dashboard, not a multi-tenant app. Don't build per-user data isolation.
- Add a `role` column to `users` (`admin` / `member`) and an `active` column (for deactivate-without-delete). First user (`/api/setup`) is `admin`; existing single user is backfilled to `admin`.
- Add an admin-only "User Management" section in Settings: create user (admin sets temp password — **no public signup**), list users, change role, deactivate/delete, enforce the **10-user cap** server-side.
- **Permission model (decided with the user, see below) — gate via a `requireAdmin` hook:**
- **Admin-only (mutating shared config):** integrations create/update/delete/test, tunnels create/delete, user management, and data export/import (`/api/data/*` — round-trips decrypted secrets).
- **All authenticated users (admin + member):** view everything (Glance/Infrastructure/BookNest/Host Metrics), use ALL the SSH/Docker tooling (Terminal, Files, Containers, Remote Desktop, connect/disconnect existing tunnels — the user explicitly OK'd members having shell/root access; trusted household/team), bookmarks CRUD (shared link hub everyone contributes to), and their own profile/password/sessions.
- A deactivated user (`active = 0`) is rejected at login and their existing sessions stop validating.
### Phase 3 — DONE (merged, deployed). Multi-user (cap: 10 seats)
Shipped in PR #28 (with a build-fix follow-up in PR #29). Both frontend and backend type-check cleanly.
- **Decision (made by the user):** dashboard data (integrations, bookmarks, tunnels, etc.) is **shared across all users**, not private per-user — household/self-hosted dashboard, not multi-tenant. No per-user data isolation was built.
- `users` gained a `role` column (`admin`/`member`, defaults to `'admin'` so the pre-existing single user keeps full access) and an `active` column (deactivate-without-delete), added via idempotent boot-time `ALTER TABLE` migrations in `backend/src/db/index.ts`. First user (`/api/setup`) is `admin`; new users are created as `member` unless promoted.
- Admin-only "User Management" section in Settings (`UsersSection` in `Settings.tsx`): create user (admin sets temp password — **no public signup**), list users, toggle role, deactivate/delete. The **10-user cap** is enforced server-side in `POST /api/users`.
- Endpoints in `auth.ts`, all behind `app.requireAdmin`: `GET /api/users`, `POST /api/users`, `PUT /api/users/:id` (role/active), `DELETE /api/users/:id`. Last-active-admin guardrails: can't demote, deactivate, or delete the final active admin; can't delete your own account. Deactivating a user deletes their sessions immediately.
- **Permission model (gated via hooks in `server.ts`):**
- `requireAdmin` (authenticates, then enforces `role === 'admin'`) and `adminOnly` (role-only, for routes already behind a plugin-level `authenticate` hook).
- `authenticate` re-reads `role`/`active` fresh from the DB on every request rather than trusting the JWT claim, so a demoted/deactivated user loses elevated access immediately even with an older token; a deactivated user is rejected (401/at login 403) and their sessions stop validating.
- **Admin-only (mutating shared config):** integrations create/update/delete/test (`adminOnly` in `integrations.ts`), tunnels create/delete (`tunnels.ts`), data export/import (`data.ts`), and user management.
- **All authenticated users (admin + member):** view everything, use ALL the SSH/Docker tooling (Terminal, Files, Containers, Remote Desktop, connect/disconnect existing tunnels), bookmarks CRUD, and their own profile/password/sessions.
- Frontend wiring: `listUsers`/`createUser`/`updateUser`/`deleteUser` + `ManagedUser` type in `src/lib/api.ts`.
### Phase 4 — NOT STARTED. Authentik SSO (OIDC)
- Add instance-level SSO config (issuer URL, client ID/secret, redirect URI) — likely an integration-like settings entry, or dedicated config table/env vars.
- `GET /api/auth/sso/login` → redirect to Authentik; `GET /api/auth/sso/callback` → exchange code, look up/create local user by SSO subject claim (respecting the 10-user cap from Phase 3), issue the same JWT format as today.
- Add a "Sign in with SSO" button on `Login.tsx` alongside username/password (local accounts remain as an admin recovery path — don't remove password auth entirely).
### Phase 4 — DEFERRED to paid add-on (AWS deployment). Authentik SSO (OIDC)
Moved out of the core build. Planned as a **paid add-on shipped when ArchNest is deployed on AWS**, not on the current `racknerd1` deployment. Full intended scope and the open scope questions now live in **`ROADMAP.md`**. Local username/password auth (Phases 1-3) stays as the free path and admin recovery path.
## Known non-blocking stubs (cosmetic, not flagged as work to do unless asked)
## Known non-blocking stubs
- `Infrastructure.tsx`'s "Network" sub-tab is **intentionally** disabled (`title="Coming soon"`) — leave alone unless explicitly asked.
- `Settings.tsx`'s Appearance section (theme/accent/fontSize/radius/sidebarExpanded/animations) is local-state-only — doesn't persist or apply anywhere. Recommended fix if picked up: mirror the Terminal page's `localStorage`-backed prefs pattern and apply via CSS variables on `:root`.
- `Settings.tsx`'s Notifications section (email/push/sound toggles) has no backing delivery mechanism — recommend removing or clearly labeling as not-yet-functional rather than persisting settings that do nothing.
Neither has been actioned because the user hasn't asked — check the latest conversation/commits before assuming a direction.
Moved to **`ROADMAP.md`** ("Known non-blocking stubs"). Summary: the Infrastructure "Network" sub-tab is intentionally disabled, and the Settings Appearance and Notifications sections are non-functional placeholders. None are flagged as work to do unless explicitly asked — check the latest conversation/commits before assuming a direction.
## Deployment (already working — reference only)
@ -103,6 +101,6 @@ Neither has been actioned because the user hasn't asked — check the latest con
1. Read this file, then `TERMIX_MIGRATION.md` for feature-level history, then skim recent `git log --oneline -30` for the latest concrete changes (commit messages are deliberately descriptive).
2. Frontend type-checks with `npx tsc --noEmit -p .` from repo root; backend the same from `backend/`. Both should pass cleanly before any commit.
3. If continuing the auth work, **Phase 2 is done** (password change + sessions + login log). **Phase 3 (multi-user) is in progress** — see its section above for the agreed permission model.
3. The auth roadmap's **Phases 1-3 are done** (user menu wiring; password change + sessions + login log; multi-user accounts with admin/member roles). **Phase 4 (Authentik SSO) is deferred to a paid AWS add-on — see `ROADMAP.md`.** There is no active auth task in the current self-hosted build.
4. If asked to add a feature unrelated to auth, follow existing patterns: integration adapters in `backend/src/integrations/`, SSH-backed engines in `backend/src/ssh/`, one route file per feature in `backend/src/routes/`, one `api.ts` entry + page component per frontend feature.
5. For anything ambiguous in scope (especially Phase 3's permission model or Phase 4's SSO provider assumptions), use `AskUserQuestion` rather than guessing — that's how Phases 24 above got scoped in the first place.
5. For anything ambiguous in scope (especially the permission model, or Phase 4's SSO scope questions in `ROADMAP.md` if that add-on gets picked up), use `AskUserQuestion` rather than guessing — that's how Phases 24 above got scoped in the first place.

View file

@ -27,16 +27,20 @@ managed host.
merge to `main` via `.github/workflows/deploy.yml`. All 11 pages and their
backend routes are built and working — there is no pending/on-hold page.
The active area of work is **the auth system**: the user menu's
Profile/Appearance/Security links were fixed in Phase 1; Phases 2-4
(password change + sessions + login audit log, multi-user accounts,
Authentik SSO) are planned but not started — see `HANDOFF.md` for the full
phase breakdown and exactly where to resume.
Profile/Appearance/Security links were fixed in Phase 1; Phase 2
(password change + sessions + login audit log) and Phase 3 (multi-user
accounts with admin/member roles, 10-seat cap) have shipped. Phase 4
(Authentik SSO) is **deferred to a paid add-on for the future AWS
deployment** — see `ROADMAP.md`. With Phases 1-3 done there is no active
auth task in the current self-hosted build; see `HANDOFF.md` for the full
phase breakdown.
If you're a fresh AI session: read this file, then `HANDOFF.md` (current
task state + standing workflow rules), then `design-decisions.md` (visual
conventions + accurate per-page implementation notes), then
`TERMIX_MIGRATION.md` (history of how the SSH/Docker/Guacamole feature set
was built) if you need historical context on a specific feature.
conventions + accurate per-page implementation notes), then `ROADMAP.md`
(deferred/planned work, incl. the paid SSO add-on) and `TERMIX_MIGRATION.md`
(history of how the SSH/Docker/Guacamole feature set was built) if you need
that context.
## Pages
@ -67,8 +71,9 @@ with the actual code, not a spec written before the page existed.
- `src/lib/api.ts` — typed fetch wrapper (`apiFetch`) + one function per
backend endpoint + matching TS interfaces. This is the contract between
frontend and backend; any new backend route needs a matching entry here.
- `src/lib/AuthContext.tsx` — auth state backed by `localStorage` (single JWT,
no session tracking yet — Phase 2 of the auth roadmap).
- `src/lib/AuthContext.tsx` — auth state backed by `localStorage` (JWT
carrying a server-tracked session id; signing out revokes the session
server-side).
- `src/pages/` — one file per route (see table above), plus `Login.tsx` /
`Enrollment.tsx` for the unauthenticated/first-run flows.
- `src/components/``TopBar.tsx` (title, global search across pages/
@ -81,14 +86,15 @@ with the actual code, not a spec written before the page existed.
### Backend (`/backend`)
- Fastify 5, TypeScript, ESM (`tsx` for dev, `tsc -b` for build), entrypoint
`src/server.ts`.
- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log.
Single-user schema today (no `role` column, no multi-account concept —
see `HANDOFF.md` Phase 3).
- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log,
plus `sessions`/`login_events` tables and a multi-user `users` schema
(`role` admin/member + `active` columns).
- `backend/src/db/crypto.ts` — AES-256-GCM `encryptSecret`/`decryptSecret`,
keyed by `ARCHNEST_SECRET_KEY`.
- `backend/src/routes/` — one file per feature area:
- `auth.ts` — setup, login, profile (`/api/setup`, `/api/auth/login`,
`/api/auth/me`)
- `auth.ts` — setup, login, profile, password change, sessions,
login audit log, and admin-only user management (`/api/setup`,
`/api/auth/*`, `/api/users`)
- `integrations.ts` — integration CRUD + connection testing
- `bookmarks.ts` — bookmarks + categories CRUD
- `events.ts` — activity log retrieval

85
ROADMAP.md Normal file
View file

@ -0,0 +1,85 @@
# ArchNest — Roadmap
Forward-looking work that is **planned but not currently being built**. For
the state of shipped work and the active task, see `HANDOFF.md`. For
historical feature build-out, see `TERMIX_MIGRATION.md`.
---
## Shipped (for context)
The auth roadmap so far — full detail in `HANDOFF.md`:
- **Phase 1** — User menu Profile/Appearance/Security wired up; `?tab=`
deep-linking in Settings.
- **Phase 2** — Password change, server-tracked sessions, login audit log.
- **Phase 3** — Multi-user accounts: admin/member roles, `active` flag,
10-seat cap, admin-only user management, `requireAdmin`/`adminOnly` gating.
---
## Phase 4 — Authentik SSO (OIDC) — PAID ADD-ON (AWS deployment)
**Status:** deferred. This is intentionally **not** part of the
self-hosted core build. It is planned as a **paid add-on, shipped when
ArchNest is deployed on AWS** — not on the current `racknerd1` deployment.
Local username/password auth (Phases 1-3) remains the free, always-available
path and the admin recovery path; SSO layers on top of it rather than
replacing it.
### Intended scope (when built)
- Instance-level SSO config (issuer URL, client ID/secret, redirect URI) —
likely an integration-like settings entry, or a dedicated config
table / env vars.
- `GET /api/auth/sso/login` → redirect to Authentik.
- `GET /api/auth/sso/callback` → exchange code, look up/create local user by
SSO subject claim (respecting the 10-user cap from Phase 3), issue the same
JWT format as today.
- "Sign in with SSO" button on `Login.tsx` alongside username/password
(local accounts remain — do **not** remove password auth entirely).
### Open scope questions (decide before any code)
1. **Where does SSO config live?** env vars (simplest, redeploy to change) vs.
a dedicated config table vs. an integration-like settings entry (editable
in-UI, more work).
2. **First-login provisioning** — auto-create a local `member` for an
unknown-but-valid SSO user (subject to the 10-seat cap), or require an
admin to pre-create the account and only *link* it on SSO login?
3. **Role mapping** — do Authentik groups/claims map to admin/member, or do
all SSO users default to `member` with roles managed locally?
---
## Terminal — window grid view (tiered: self-hosted vs. paid)
**Status:** self-hosted behavior is current; the paid tier is planned.
The Terminal page (`src/pages/Terminal.tsx`) supports a split-pane grid view
within a tab.
- **Self-hosted (current):** capped at a **4-window grid** (1 / 2 / 4 pane
layouts via the toolbar buttons). This is the free, always-available tier.
- **Paid (planned, AWS deployment):** **as many windows as fit on the
screen** — dynamic grid sizing beyond the 4-pane cap, laid out responsively
to the viewport rather than a fixed 1/2/4 choice.
When the paid tier is built, the 4-pane cap becomes a licensing/feature gate
rather than a hard UI limit; the grid layout logic generalizes to an
arbitrary pane count.
---
## Known non-blocking stubs (cosmetic, not scheduled)
Not flagged as work to do unless explicitly asked:
- `Infrastructure.tsx`'s "Network" sub-tab is **intentionally** disabled
(`title="Coming soon"`) — leave alone unless explicitly asked.
- `Settings.tsx`'s Appearance section (theme/accent/fontSize/radius/
sidebarExpanded/animations) is local-state-only — doesn't persist or apply
anywhere. Recommended fix if picked up: mirror the Terminal page's
`localStorage`-backed prefs pattern and apply via CSS variables on `:root`.
- `Settings.tsx`'s Notifications section (email/push/sound toggles) has no
backing delivery mechanism — recommend removing or clearly labeling as
not-yet-functional rather than persisting settings that do nothing.

View file

@ -257,8 +257,9 @@ an actual SQL-backed or live-polled endpoint, not a config file or static array.
- `backend/` — Fastify 5 + TypeScript (ESM, `tsx` dev / `tsc -b` build),
`better-sqlite3` for storage, deployed as its own Docker container alongside
the frontend and a `guacd` sidecar.
- Auth: single-user-schema JWT (`@fastify/jwt`) today — see `HANDOFF.md` for
the multi-user/SSO roadmap (Phases 2-4, not yet built).
- Auth: JWT (`@fastify/jwt`) with server-tracked sessions and a multi-user
schema (admin/member roles, 10-seat cap). Authentik SSO is deferred to a
paid AWS add-on — see `ROADMAP.md`.
- Integration credentials split across `integrations` (non-secret config) and
`secrets` (AES-256-GCM-encrypted, keyed by `ARCHNEST_SECRET_KEY`) tables —
secrets never appear in a generic "list integrations" response by

View file

@ -0,0 +1,389 @@
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { getToken } from './api'
import { useAuth } from './AuthContext'
import {
TERM_THEMES,
loadPrefs,
savePrefs,
type TabState,
type TerminalPrefs,
} from './terminalPrefs'
/**
* One live terminal pane: its xterm instance, fit addon, WebSocket, and the
* persistent wrapper DOM node that xterm is attached to. The wrapper lives in
* a hidden container at the app root and is re-parented into the Terminal page
* grid when that page is mounted, so the xterm instance and its WebSocket
* survive route changes (the page unmounting no longer disposes them).
*/
interface PaneSession {
paneId: number
wrapper: HTMLDivElement
term: XTerm
fit: FitAddon
ws: WebSocket | null
hostId: number | null
connected: boolean
/** Last tmux session list fetched for the current host. */
tmuxSessions: string[]
/** Currently-selected tmux option in the pane header (raw select value). */
selectedTmux: string
/** Disposes the term's onData/onResize listeners before re-wiring on reconnect. */
disposeListeners: (() => void) | null
}
let nextId = 1
const genId = () => nextId++
function newTab(): TabState {
return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] }
}
interface TerminalSessionContextValue {
tabs: TabState[]
activeTabId: number
activePaneId: number | null
prefs: TerminalPrefs
setActiveTabId: (id: number) => void
setActivePaneId: (id: number | null) => void
setPrefs: (update: (p: TerminalPrefs) => TerminalPrefs) => void
addTab: () => void
closeTab: (id: number) => void
setPaneCount: (count: number) => void
setPaneHost: (paneId: number, hostId: number) => void
/** Mount a pane's persistent DOM node into a grid cell; returns a detach fn. */
attachPane: (paneId: number, cell: HTMLElement) => () => void
/** Read-only snapshot of a pane's live connection state (for header UI). */
getPaneInfo: (paneId: number) => { connected: boolean; tmuxSessions: string[]; selectedTmux: string }
/** Reconnect a pane to a tmux session (or plain shell) via the header select. */
reconnectTmux: (paneId: number, rawSelectValue: string) => void
/** Version counter that bumps whenever live pane state changes, to trigger re-render. */
version: number
}
const TerminalSessionContext = createContext<TerminalSessionContextValue | null>(null)
export function TerminalSessionProvider({ children }: { children: ReactNode }) {
const { status } = useAuth()
const [tabs, setTabs] = useState<TabState[]>(() => [newTab()])
const [activeTabId, setActiveTabId] = useState(() => tabs[0].id)
const [activePaneId, setActivePaneId] = useState<number | null>(null)
const [prefs, setPrefsState] = useState<TerminalPrefs>(loadPrefs)
const [version, setVersion] = useState(0)
// Hidden host for offscreen pane wrappers; created lazily on first use (never
// during render — only from effects/handlers — to satisfy the rules of refs).
const hiddenRootRef = useRef<HTMLDivElement | null>(null)
function getHiddenRoot(): HTMLDivElement {
let el = hiddenRootRef.current
if (!el) {
el = document.createElement('div')
el.style.position = 'absolute'
el.style.width = '0'
el.style.height = '0'
el.style.overflow = 'hidden'
el.style.left = '-99999px'
el.style.top = '0'
hiddenRootRef.current = el
}
if (!el.isConnected) document.body.appendChild(el)
return el
}
const sessionsRef = useRef<Map<number, PaneSession>>(new Map())
useEffect(() => {
const hidden = getHiddenRoot()
return () => {
if (hidden.isConnected) hidden.remove()
}
}, [])
useEffect(() => savePrefs(prefs), [prefs])
const bump = () => setVersion((v) => v + 1)
// --- Session lifecycle ----------------------------------------------------
function ensureSession(paneId: number): PaneSession {
const existing = sessionsRef.current.get(paneId)
if (existing) return existing
const wrapper = document.createElement('div')
wrapper.style.width = '100%'
wrapper.style.height = '100%'
wrapper.style.minHeight = '0'
getHiddenRoot().appendChild(wrapper)
const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0]
const term = new XTerm({
cursorBlink: true,
fontSize: prefs.fontSize,
fontFamily: prefs.fontFamily,
theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor },
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(wrapper)
const session: PaneSession = {
paneId,
wrapper,
term,
fit,
ws: null,
hostId: null,
connected: false,
tmuxSessions: [],
selectedTmux: '',
disposeListeners: null,
}
sessionsRef.current.set(paneId, session)
return session
}
function applyPrefsToSession(s: PaneSession) {
const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0]
s.term.options.fontSize = prefs.fontSize
s.term.options.fontFamily = prefs.fontFamily
s.term.options.theme = { background: theme.background, foreground: theme.foreground, cursor: theme.cursor }
try {
s.fit.fit()
} catch {
/* container may be detached */
}
}
function fetchTmuxSessions(s: PaneSession, id: number) {
s.tmuxSessions = []
bump()
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id }))
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'tmux_sessions') {
s.tmuxSessions = msg.sessions ?? []
bump()
ws.close()
}
}
}
function connect(s: PaneSession, id: number, tmuxSession?: string) {
s.disposeListeners?.()
s.ws?.close()
s.connected = false
bump()
const term = s.term
term.reset()
term.writeln('Connecting…')
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
s.ws = ws
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
s.connected = true
bump()
term.reset()
} else if (msg.type === 'data') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`)
s.connected = false
bump()
} else if (msg.type === 'closed') {
term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m')
s.connected = false
bump()
}
}
ws.onclose = () => {
s.connected = false
bump()
}
const dataDisp = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }))
})
const resizeDisp = term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }))
})
s.disposeListeners = () => {
dataDisp.dispose()
resizeDisp.dispose()
}
}
function destroySession(paneId: number) {
const s = sessionsRef.current.get(paneId)
if (!s) return
s.disposeListeners?.()
s.ws?.close()
s.term.dispose()
s.wrapper.remove()
sessionsRef.current.delete(paneId)
}
function destroyAllSessions() {
for (const paneId of [...sessionsRef.current.keys()]) destroySession(paneId)
}
// Tear everything down on logout (sessions must not outlive the auth session).
useEffect(() => {
if (status === 'logged-out' || status === 'needs-setup') {
destroyAllSessions()
// Defer the state reset so we don't call setState synchronously inside the
// effect body (which would trigger a cascading render).
queueMicrotask(() => {
const fresh = newTab()
setTabs([fresh])
setActiveTabId(fresh.id)
setActivePaneId(null)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status])
// --- Public actions -------------------------------------------------------
function setPrefs(update: (p: TerminalPrefs) => TerminalPrefs) {
setPrefsState((prev) => {
const next = update(prev)
// Re-style any live sessions in place rather than recreating them.
queueMicrotask(() => {
for (const s of sessionsRef.current.values()) applyPrefsToSession(s)
})
return next
})
}
function addTab() {
const tab = newTab()
setTabs((prev) => [...prev, tab])
setActiveTabId(tab.id)
}
function closeTab(id: number) {
setTabs((prev) => {
const closing = prev.find((t) => t.id === id)
// Closing a tab disconnects its panes' sessions.
closing?.panes.forEach((p) => destroySession(p.id))
const next = prev.filter((t) => t.id !== id)
if (next.length === 0) {
const t = newTab()
setActiveTabId(t.id)
return [t]
}
if (id === activeTabId) setActiveTabId(next[0].id)
return next
})
}
function updateActiveTab(fn: (tab: TabState) => TabState) {
setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t)))
}
function setPaneCount(count: number) {
updateActiveTab((tab) => {
const panes = [...tab.panes]
while (panes.length < count) panes.push({ id: genId(), hostId: null })
while (panes.length > count) {
const removed = panes.pop()
// Reducing the grid disconnects the dropped pane's session.
if (removed) destroySession(removed.id)
}
return { ...tab, panes }
})
}
function setPaneHost(paneId: number, hostId: number) {
updateActiveTab((tab) => ({
...tab,
panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)),
}))
setActivePaneId(paneId)
const s = ensureSession(paneId)
if (s.hostId === hostId) return
s.hostId = hostId
s.selectedTmux = ''
fetchTmuxSessions(s, hostId)
connect(s, hostId)
}
function attachPane(paneId: number, cell: HTMLElement): () => void {
const s = ensureSession(paneId)
cell.appendChild(s.wrapper)
// Defer the fit until layout has settled in the new parent.
requestAnimationFrame(() => {
try {
s.fit.fit()
} catch {
/* ignore */
}
})
return () => {
// On page unmount, move the wrapper back to the hidden root instead of
// disposing it, so the xterm + WebSocket keep running in the background.
const hidden = getHiddenRoot()
if (s.wrapper.parentElement !== hidden) hidden.appendChild(s.wrapper)
}
}
function getPaneInfo(paneId: number) {
const s = sessionsRef.current.get(paneId)
return {
connected: s?.connected ?? false,
tmuxSessions: s?.tmuxSessions ?? [],
selectedTmux: s?.selectedTmux ?? '',
}
}
function reconnectTmux(paneId: number, rawSelectValue: string) {
const s = sessionsRef.current.get(paneId)
if (!s || s.hostId === null) return
s.selectedTmux = rawSelectValue
const value = rawSelectValue === '__new__' ? `archnest-${Date.now().toString(36)}` : rawSelectValue
connect(s, s.hostId, value || undefined)
}
const value: TerminalSessionContextValue = {
tabs,
activeTabId,
activePaneId,
prefs,
setActiveTabId,
setActivePaneId,
setPrefs,
addTab,
closeTab,
setPaneCount,
setPaneHost,
attachPane,
getPaneInfo,
reconnectTmux,
version,
}
return <TerminalSessionContext.Provider value={value}>{children}</TerminalSessionContext.Provider>
}
export function useTerminalSessions() {
const ctx = useContext(TerminalSessionContext)
if (!ctx) throw new Error('useTerminalSessions must be used within TerminalSessionProvider')
return ctx
}

56
src/lib/terminalPrefs.ts Normal file
View file

@ -0,0 +1,56 @@
// Shared terminal constants, types, and prefs persistence. Kept in a non-component
// module so the context file can export only its provider/hook (React Fast Refresh
// requires component files to export components exclusively).
const GOLD = '#C8A434'
export interface TermTheme {
name: string
background: string
foreground: string
cursor: string
}
export const TERM_THEMES: TermTheme[] = [
{ name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
{ name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' },
{ name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' },
{ name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' },
]
export interface TerminalPrefs {
themeName: string
fontSize: number
fontFamily: string
}
export interface PaneState {
id: number
hostId: number | null
}
export interface TabState {
id: number
name: string
panes: PaneState[]
}
const PREFS_KEY = 'archnest-terminal-prefs'
export function defaultPrefs(): TerminalPrefs {
return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }
}
export function loadPrefs(): TerminalPrefs {
try {
const raw = localStorage.getItem(PREFS_KEY)
if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) }
} catch {
/* ignore malformed local storage */
}
return defaultPrefs()
}
export function savePrefs(prefs: TerminalPrefs) {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs))
}

View file

@ -4,12 +4,15 @@ import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from './lib/AuthContext'
import { TerminalSessionProvider } from './lib/TerminalSessionContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<TerminalSessionProvider>
<App />
</TerminalSessionProvider>
</AuthProvider>
</BrowserRouter>
</StrictMode>,

View file

@ -1,27 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react'
import { api, getToken, type Integration } from '../lib/api'
import { api, type Integration } from '../lib/api'
import { useTerminalSessions } from '../lib/TerminalSessionContext'
import { TERM_THEMES } from '../lib/terminalPrefs'
const GOLD = '#C8A434'
const TEXT_SECONDARY = '#7A7D85'
interface TermTheme {
name: string
background: string
foreground: string
cursor: string
}
const TERM_THEMES: TermTheme[] = [
{ name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
{ name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' },
{ name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' },
{ name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' },
]
const FONT_SIZES = [11, 12, 13, 14, 15, 16]
const FONT_FAMILIES = [
{ name: 'Monospace', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
@ -29,108 +14,32 @@ const FONT_FAMILIES = [
{ name: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace' },
]
interface TerminalPrefs {
themeName: string
fontSize: number
fontFamily: string
}
const PREFS_KEY = 'archnest-terminal-prefs'
function loadPrefs(): TerminalPrefs {
try {
const raw = localStorage.getItem(PREFS_KEY)
if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) }
} catch {
/* ignore malformed local storage */
}
return defaultPrefs()
}
function defaultPrefs(): TerminalPrefs {
return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: FONT_FAMILIES[0].value }
}
function savePrefs(prefs: TerminalPrefs) {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs))
}
interface PaneState {
id: number
hostId: number | null
}
interface TabState {
id: number
name: string
panes: PaneState[]
}
let nextId = 1
const genId = () => nextId++
function newTab(): TabState {
return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] }
}
export default function Terminal() {
const [hosts, setHosts] = useState<Integration[]>([])
const [tabs, setTabs] = useState<TabState[]>(() => [newTab()])
const [activeTabId, setActiveTabId] = useState(() => tabs[0].id)
const [activePaneId, setActivePaneId] = useState<number | null>(null)
const [prefs, setPrefs] = useState<TerminalPrefs>(loadPrefs)
const [showPrefs, setShowPrefs] = useState(false)
const {
tabs,
activeTabId,
activePaneId,
prefs,
setActiveTabId,
setActivePaneId,
setPrefs,
addTab,
closeTab,
setPaneCount,
setPaneHost,
} = useTerminalSessions()
useEffect(() => {
api.listIntegrations().then(({ integrations }) => {
setHosts(integrations.filter((i) => i.type === 'ssh'))
})
}, [])
useEffect(() => savePrefs(prefs), [prefs])
const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]
function addTab() {
const tab = newTab()
setTabs((prev) => [...prev, tab])
setActiveTabId(tab.id)
}
function closeTab(id: number) {
setTabs((prev) => {
const next = prev.filter((t) => t.id !== id)
if (next.length === 0) {
const t = newTab()
setActiveTabId(t.id)
return [t]
}
if (id === activeTabId) setActiveTabId(next[0].id)
return next
})
}
function updateActiveTab(fn: (tab: TabState) => TabState) {
setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t)))
}
function setPaneCount(count: number) {
updateActiveTab((tab) => {
const panes = [...tab.panes]
while (panes.length < count) panes.push({ id: genId(), hostId: null })
while (panes.length > count) panes.pop()
return { ...tab, panes }
})
}
function setPaneHost(paneId: number, hostId: number) {
updateActiveTab((tab) => ({
...tab,
panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)),
}))
setActivePaneId(paneId)
}
const paneGridClass =
activeTab.panes.length === 1
? 'grid-cols-1 grid-rows-1'
@ -138,6 +47,8 @@ export default function Terminal() {
? 'grid-cols-2 grid-rows-1'
: 'grid-cols-2 grid-rows-2'
const activePaneHostId = activeTab.panes.find((p) => p.id === activePaneId)?.hostId ?? null
return (
<div className="flex h-full gap-4">
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
@ -156,8 +67,8 @@ export default function Terminal() {
onClick={() => activePaneId !== null && setPaneHost(activePaneId, h.id)}
className="rounded-md px-2 py-1.5 text-left text-sm transition-colors"
style={{
background: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? GOLD : '#E8E6E0',
background: activePaneHostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: activePaneHostId === h.id ? GOLD : '#E8E6E0',
}}
>
{h.name}
@ -165,7 +76,8 @@ export default function Terminal() {
))}
</div>
<p className="mt-3 text-[11px]" style={{ color: TEXT_SECONDARY }}>
Click a pane, then a host to connect it.
Click a pane, then a host to connect it. Sessions stay connected when
you switch pages close a tab or pane to disconnect.
</p>
</div>
@ -264,9 +176,9 @@ export default function Terminal() {
{activeTab.panes.map((pane) => (
<TerminalPane
key={pane.id}
paneId={pane.id}
hostId={pane.hostId}
hosts={hosts}
prefs={prefs}
active={pane.id === activePaneId}
onFocus={() => setActivePaneId(pane.id)}
/>
@ -278,122 +190,34 @@ export default function Terminal() {
}
function TerminalPane({
paneId,
hostId,
hosts,
prefs,
active,
onFocus,
}: {
paneId: number
hostId: number | null
hosts: Integration[]
prefs: TerminalPrefs
active: boolean
onFocus: () => void
}) {
const [connected, setConnected] = useState(false)
const [tmuxSessions, setTmuxSessions] = useState<string[]>([])
const [selectedTmux, setSelectedTmux] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerm | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const lastHostIdRef = useRef<number | null>(null)
const { attachPane, getPaneInfo, reconnectTmux, version } = useTerminalSessions()
const cellRef = useRef<HTMLDivElement>(null)
// Mount the persistent xterm DOM node owned by the provider into this cell;
// on unmount (route change or layout change) the wrapper is moved back to the
// hidden root rather than disposed, so the session keeps running.
useEffect(() => {
if (!containerRef.current) return
const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0]
const term = new XTerm({
cursorBlink: true,
fontSize: prefs.fontSize,
fontFamily: prefs.fontFamily,
theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor },
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(containerRef.current)
fit.fit()
termRef.current = term
fitRef.current = fit
const onResize = () => fit.fit()
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
term.dispose()
wsRef.current?.close()
}
if (!cellRef.current) return
const detach = attachPane(paneId, cellRef.current)
return detach
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefs.themeName, prefs.fontSize, prefs.fontFamily])
useEffect(() => {
fitRef.current?.fit()
})
useEffect(() => {
if (hostId === null || hostId === lastHostIdRef.current) return
lastHostIdRef.current = hostId
setSelectedTmux('')
fetchTmuxSessions(hostId)
connect(hostId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId])
function fetchTmuxSessions(id: number) {
setTmuxSessions([])
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id }))
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'tmux_sessions') {
setTmuxSessions(msg.sessions ?? [])
ws.close()
}
}
}
function connect(id: number, tmuxSession?: string) {
wsRef.current?.close()
setConnected(false)
const term = termRef.current
if (!term) return
term.reset()
term.writeln('Connecting…')
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
wsRef.current = ws
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
setConnected(true)
term.reset()
} else if (msg.type === 'data') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`)
setConnected(false)
} else if (msg.type === 'closed') {
term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m')
setConnected(false)
}
}
ws.onclose = () => setConnected(false)
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }))
})
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }))
})
}
}, [paneId])
// `version` is read so the header re-renders when live pane state changes.
void version
const info = getPaneInfo(paneId)
const host = hosts.find((h) => h.id === hostId)
return (
@ -406,25 +230,20 @@ function TerminalPane({
}}
>
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
<span className="inline-block h-2 w-2 rounded-full" style={{ background: connected ? '#2ECC71' : '#7A7D85' }} />
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
<span className="inline-block h-2 w-2 rounded-full" style={{ background: info.connected ? '#2ECC71' : '#7A7D85' }} />
{host ? (info.connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
{host && (
<select
value={selectedTmux}
value={info.selectedTmux}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const raw = e.target.value
const value = raw === '__new__' ? `archnest-${Date.now().toString(36)}` : raw
setSelectedTmux(raw)
connect(host.id, value || undefined)
}}
onChange={(e) => reconnectTmux(paneId, e.target.value)}
className="ml-auto rounded-md border border-white/10 bg-transparent px-1.5 py-0.5"
style={{ color: TEXT_SECONDARY, fontSize: '11px' }}
title="Attach to a tmux session on this host"
>
<option value="">Plain shell</option>
<option value="__new__">New tmux session</option>
{tmuxSessions.map((s) => (
{info.tmuxSessions.map((s) => (
<option key={s} value={s}>
tmux: {s}
</option>
@ -432,7 +251,7 @@ function TerminalPane({
</select>
)}
</div>
<div ref={containerRef} className="min-h-0 flex-1" />
<div ref={cellRef} className="min-h-0 flex-1" />
</div>
)
}