diff --git a/CLAUDE.md b/CLAUDE.md index e4ee894..8ca437a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ The Makefile's `dev-backend` target and `run.sh` both conditionally set `NODE_EX | GET/POST | `/api/sessions` | `routes/sessions.ts` | List sessions, create session | | POST | `/api/sessions/:id/stop` | `routes/sessions.ts` | Stop active session | | GET/POST | `/api/settings` | `routes/settings.ts` | Persistent key-value settings (SQLite-backed) | +| GET | `/api/system` | `routes/system.ts` | Read-only runtime info (SQLite DB path) | | GET | `/health` | `server.ts` | Health check | | WS | `/ws/session/:id` | `ws/session.ts` | Chat PTY I/O over WebSocket | | WS | `/ws/terminal/:id` | `ws/terminal.ts` | Terminal PTY I/O over WebSocket | @@ -88,7 +89,7 @@ Two modes are supported per session, stored in the `sessions.mode` column: ### Frontend data flow -- `useDashboard.ts` fetches account + usage + sessions in parallel with a 60s auto-refresh +- `useHomeData.ts` fetches account + usage + sessions in parallel with a 60s auto-refresh - `SessionContext.tsx` holds the active session state (including `mode`); `useWebSocket.ts` manages the chat WS connection; `useTerminalSession.ts` manages the terminal WS connection - `TerminalSession.tsx` and `TerminalDrawer.tsx` are **never unmounted** — both are CSS-toggled (`display: none`) to preserve xterm scroll buffer across navigation @@ -103,13 +104,13 @@ Two modes are supported per session, stored in the `sessions.mode` column: ## Docker ```bash -docker build -t claude-code-dashboard . +docker build -t claude-code-webui . docker-compose up -# Dashboard at http://localhost:8080 +# Web UI at http://localhost:8080 ``` `docker-compose.yml` mounts `~/.claude` (read-write — required for session deletion) and `~/projects` (read-write) from the host. ## Systemd service (Linux) -`scripts/install-service.sh` generates a systemd user unit from `scripts/claude-code-dashboard.service.template`, writes an env file to `~/.config/systemd/user/`, builds the project, and enables the service. Run with `--skip-build` to reuse existing `dist/` directories. +`scripts/install-service.sh` generates a systemd user unit from `scripts/claude-code-webui.service.template`, writes an env file to `~/.config/systemd/user/`, builds the project, and enables the service. Run with `--skip-build` to reuse existing `dist/` directories. diff --git a/README.md b/README.md index 4b53596..b7eb275 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ make service-install ARGS=--skip-build ```bash make service-uninstall -systemctl --user status claude-code-dashboard -journalctl --user -u claude-code-dashboard -f +systemctl --user status claude-code-webui +journalctl --user -u claude-code-webui -f ``` ## Commands reference diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 42e67fc..86b64b7 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,9 +1,10 @@ import Database from 'better-sqlite3' import path from 'path' import fs from 'fs' +import os from 'os' -const DB_DIR = process.env.DATA_DIR ?? path.join(process.env.HOME ?? '/root', '.claude', 'dashboard') -const DB_PATH = path.join(DB_DIR, 'dashboard.db') +const DB_DIR = process.env.DATA_DIR ?? path.join(os.homedir(), '.claude', 'webui') +export const DB_PATH = path.join(DB_DIR, 'webui.db') function initDb(): Database.Database { fs.mkdirSync(DB_DIR, { recursive: true }) diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts new file mode 100644 index 0000000..a9d7e87 --- /dev/null +++ b/backend/src/routes/system.ts @@ -0,0 +1,8 @@ +import type { FastifyInstance } from 'fastify' +import { DB_PATH } from '../db/schema' + +export async function systemRoutes(fastify: FastifyInstance) { + fastify.get('/api/system', async (_req, reply) => { + return reply.send({ db_path: DB_PATH }) + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index b26f16c..4b35463 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,12 +10,13 @@ import { sessionRoutes } from './routes/sessions' import { sessionWsRoutes } from './ws/session' import { terminalWsRoutes } from './ws/terminal' import { settingsRoutes } from './routes/settings' +import { systemRoutes } from './routes/system' import { initAccountCache } from './services/accountCache' import { statuslineRoutes } from './routes/statusline' import { setupStatusline } from './lib/setup-statusline' // TODO: add bearer token auth — add @fastify/bearer-auth plugin here -// and set token via DASHBOARD_TOKEN env var +// and set token via WEBUI_TOKEN env var // fastify.addHook('onRequest', async (request, reply) => { ... }) const fastify = Fastify({ logger: true }) @@ -42,6 +43,7 @@ async function start() { await fastify.register(sessionWsRoutes) await fastify.register(terminalWsRoutes) await fastify.register(settingsRoutes) + await fastify.register(systemRoutes) await fastify.register(statuslineRoutes) fastify.get('/health', async () => ({ status: 'ok' })) diff --git a/docker-compose.yml b/docker-compose.yml index 4bebfed..15c8616 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - dashboard: + webui: image: ghcr.io/adeotek/claude-code-webui:latest build: context: . diff --git a/docs/plans/2026-05-12-terminal-mode.md b/docs/plans/2026-05-12-terminal-mode.md deleted file mode 100644 index 296b8dc..0000000 --- a/docs/plans/2026-05-12-terminal-mode.md +++ /dev/null @@ -1,1029 +0,0 @@ -# Terminal Mode Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a global `session_mode` setting (`chat` | `terminal`) that switches the session content area between the existing chat layout and a full interactive PTY terminal backed by a new `/ws/terminal/:id` WebSocket endpoint. - -**Architecture:** A separate `ws/terminal.ts` module handles interactive PTY sessions (no JSON parsing, raw I/O passthrough) alongside the untouched `ws/session.ts`. Sessions carry their mode in the DB; `SessionContext` holds `mode` for the active session, and `DashboardView` renders either `TerminalSession` (new) or the existing chat layout based on it. - -**Tech Stack:** node-pty, better-sqlite3, Fastify WebSocket, xterm.js + FitAddon, React + TypeScript, Tailwind CSS - ---- - -## File Map - -**Create:** -- `backend/src/ws/terminal.ts` — TerminalManager singleton + `/ws/terminal/:id` WS handler -- `frontend/src/hooks/useTerminalSession.ts` — WS hook for `/ws/terminal/:id` -- `frontend/src/components/TerminalSession.tsx` — full-height xterm.js terminal component - -**Modify:** -- `backend/src/db/schema.ts` — add `mode` column migration guard to sessions table -- `backend/src/routes/settings.ts` — add `session_mode` to DEFAULTS/ALLOWED -- `backend/src/routes/sessions.ts` — POST stores mode; stop+delete kill terminal PTY -- `backend/src/server.ts` — register `terminalWsRoutes` -- `frontend/src/context/SessionContext.tsx` — add `mode` to SessionState + actions -- `frontend/src/hooks/useDashboard.ts` — add `mode` to Session type; fetch settings; return `defaultSessionMode` -- `frontend/src/hooks/useWebSocket.ts` — skip connecting when `state.mode === 'terminal'` -- `frontend/src/views/DashboardView.tsx` — dispatch mode on create/resume; swap content area -- `frontend/src/views/SettingsView.tsx` — add Session Mode toggle - ---- - -## Task 1: DB migration + settings key - -**Files:** -- Modify: `backend/src/db/schema.ts` -- Modify: `backend/src/routes/settings.ts` - -- [ ] **Step 1: Add `mode` column migration guard to `schema.ts`** - - In `backend/src/db/schema.ts`, add this block after the existing `working_time_ms` migration guard (after line 69): - - ```typescript - if (!sessionCols.find((c) => c.name === 'mode')) { - db.prepare("ALTER TABLE sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'chat'").run() - } - ``` - -- [ ] **Step 2: Add `session_mode` to settings DEFAULTS** - - In `backend/src/routes/settings.ts`, replace lines 4-6: - - ```typescript - const DEFAULTS: Record = { - bypass_permissions: 'true', - session_mode: 'chat', - } - ``` - -- [ ] **Step 3: Lint backend** - - ```bash - cd backend && npm run lint - ``` - Expected: no errors. - -- [ ] **Step 4: Commit** - - ```bash - git add backend/src/db/schema.ts backend/src/routes/settings.ts - git commit -m "feat: add session mode DB column and settings key" - ``` - ---- - -## Task 2: Terminal WebSocket handler - -**Files:** -- Create: `backend/src/ws/terminal.ts` - -- [ ] **Step 1: Create `backend/src/ws/terminal.ts`** - - ```typescript - import * as pty from 'node-pty' - import * as os from 'os' - import * as path from 'path' - import type { WebSocket } from 'ws' - import type { FastifyInstance } from 'fastify' - import { db } from '../db/schema' - - const IDLE_TIMEOUT_MS = 30 * 60 * 1000 - - function getBypassPermissions(): boolean { - const row = db - .prepare('SELECT value FROM settings WHERE key = ?') - .get('bypass_permissions') as { value: string } | undefined - return row ? row.value === 'true' : true - } - - class ActiveTerminalSession { - private ptyProc: pty.IPty | null = null - private sockets = new Set() - private idleTimer: NodeJS.Timeout | null = null - - constructor( - readonly id: string, - private readonly workdir: string, - ) { - this.spawnPty() - this.resetIdle() - } - - private spawnPty() { - const claudeBin = process.env.CLAUDE_BIN ?? 'claude' - const bypassPermissions = getBypassPermissions() - const args: string[] = [] - if (bypassPermissions) args.push('--dangerously-skip-permissions') - - const resolvedCwd = this.workdir.startsWith('~') - ? path.join(os.homedir(), this.workdir.slice(1)) - : this.workdir - - try { - this.ptyProc = pty.spawn(claudeBin, args, { - name: 'xterm-256color', - cols: 220, - rows: 50, - cwd: resolvedCwd, - env: { ...process.env } as Record, - }) - } catch (err) { - this.broadcast({ type: 'output', data: `\r\nError starting terminal: ${(err as Error).message}\r\n` }) - this.broadcast({ type: 'status', state: 'disconnected' }) - return - } - - this.ptyProc.onData((data) => { - this.resetIdle() - this.broadcast({ type: 'output', data }) - }) - - this.ptyProc.onExit(() => { - this.ptyProc = null - db.prepare('UPDATE sessions SET ended_at = ? WHERE id = ?').run(Date.now(), this.id) - this.broadcast({ type: 'status', state: 'disconnected' }) - if (this.idleTimer) clearTimeout(this.idleTimer) - }) - } - - attach(ws: WebSocket) { - this.sockets.add(ws) - ws.send(JSON.stringify({ type: 'status', state: 'connected' })) - ws.on('close', () => this.sockets.delete(ws)) - } - - writeInput(data: string) { - this.resetIdle() - this.ptyProc?.write(data) - } - - resize(cols: number, rows: number) { - this.ptyProc?.resize(cols, rows) - } - - kill() { - db.prepare('UPDATE sessions SET ended_at = ? WHERE id = ?').run(Date.now(), this.id) - if (this.ptyProc) { - this.ptyProc.kill() - this.ptyProc = null - } - if (this.idleTimer) clearTimeout(this.idleTimer) - this.broadcast({ type: 'status', state: 'disconnected' }) - } - - private broadcast(msg: { type: string; data?: string; state?: string }) { - const payload = JSON.stringify(msg) - for (const ws of this.sockets) { - if (ws.readyState === ws.OPEN) ws.send(payload) - } - } - - private resetIdle() { - if (this.idleTimer) clearTimeout(this.idleTimer) - this.idleTimer = setTimeout(() => this.kill(), IDLE_TIMEOUT_MS) - } - } - - class TerminalManager { - private sessions = new Map() - - getOrCreate(id: string, workdir: string): ActiveTerminalSession { - if (!this.sessions.has(id)) { - this.sessions.set(id, new ActiveTerminalSession(id, workdir)) - } - return this.sessions.get(id)! - } - - kill(id: string) { - this.sessions.get(id)?.kill() - this.sessions.delete(id) - } - } - - export const terminalManager = new TerminalManager() - - export async function terminalWsRoutes(fastify: FastifyInstance) { - fastify.get<{ Params: { id: string } }>( - '/ws/terminal/:id', - { websocket: true }, - (socket, req) => { - const { id } = req.params - - const row = db.prepare('SELECT workdir, ended_at FROM sessions WHERE id = ?').get(id) as - | { workdir: string; ended_at: number | null } - | undefined - - if (!row) { - socket.send(JSON.stringify({ type: 'status', state: 'error', data: 'session not found' })) - socket.close() - return - } - - if (row.ended_at !== null) { - db.prepare('UPDATE sessions SET ended_at = NULL WHERE id = ?').run(id) - } - - const session = terminalManager.getOrCreate(id, row.workdir) - session.attach(socket) - - socket.on('message', (raw: Buffer | string) => { - try { - const msg = JSON.parse(raw.toString()) as { - type: string - data?: string - cols?: number - rows?: number - } - if (msg.type === 'input' && msg.data) { - session.writeInput(msg.data) - } else if (msg.type === 'resize' && msg.cols && msg.rows) { - session.resize(msg.cols, msg.rows) - } - } catch { - // ignore malformed frames - } - }) - }, - ) - } - ``` - -- [ ] **Step 2: Lint backend** - - ```bash - cd backend && npm run lint - ``` - Expected: no errors. - -- [ ] **Step 3: Commit** - - ```bash - git add backend/src/ws/terminal.ts - git commit -m "feat: add terminal WS handler and TerminalManager" - ``` - ---- - -## Task 3: Sessions routes + server registration - -**Files:** -- Modify: `backend/src/routes/sessions.ts` -- Modify: `backend/src/server.ts` - -- [ ] **Step 1: Import `terminalManager` in `sessions.ts`** - - Replace the import block at the top of `backend/src/routes/sessions.ts` (lines 1-8): - - ```typescript - import type { FastifyInstance } from 'fastify' - import { v4 as uuidv4 } from 'uuid' - import fs from 'fs' - import os from 'os' - import path from 'path' - import { db } from '../db/schema' - import { sessionManager } from '../ws/session' - import { terminalManager } from '../ws/terminal' - ``` - -- [ ] **Step 2: Store mode in POST `/api/sessions`** - - Replace the POST handler body (lines 45-57): - - ```typescript - fastify.post<{ Body: { workdir: string; name?: string } }>('/api/sessions', async (req, reply) => { - const { workdir, name } = req.body - if (!workdir || typeof workdir !== 'string') { - return reply.status(400).send({ error: 'workdir is required' }) - } - - const modeRow = db - .prepare("SELECT value FROM settings WHERE key = 'session_mode'") - .get() as { value: string } | undefined - const mode = modeRow?.value === 'terminal' ? 'terminal' : 'chat' - - const id = uuidv4() - db.prepare( - 'INSERT INTO sessions (id, workdir, name, mode, started_at) VALUES (?, ?, ?, ?, ?)', - ).run(id, workdir, name?.trim() || null, mode, Date.now()) - - return reply.status(201).send({ sessionId: id }) - }) - ``` - -- [ ] **Step 3: Kill terminal PTY on stop** - - Replace the stop handler body (lines 59-63): - - ```typescript - fastify.post<{ Params: { id: string } }>('/api/sessions/:id/stop', async (req, reply) => { - const { id } = req.params - sessionManager.kill(id) - terminalManager.kill(id) - db.prepare('UPDATE sessions SET ended_at = ? WHERE id = ?').run(Date.now(), id) - return reply.send({ ok: true }) - }) - ``` - -- [ ] **Step 4: Kill terminal PTY on delete** - - Replace the delete handler body (lines 94-105): - - ```typescript - fastify.delete<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => { - const { id } = req.params - const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id) - if (!session) { - return reply.status(404).send({ error: 'Session not found' }) - } - sessionManager.kill(id) - terminalManager.kill(id) - db.prepare('DELETE FROM messages WHERE session_id = ?').run(id) - db.prepare('DELETE FROM sessions WHERE id = ?').run(id) - return reply.send({ ok: true }) - }) - ``` - -- [ ] **Step 5: Register `terminalWsRoutes` in `server.ts`** - - In `backend/src/server.ts`, add the import at line 10 (after the existing `sessionWsRoutes` import): - - ```typescript - import { terminalWsRoutes } from './ws/terminal' - ``` - - And register the route after the existing `sessionWsRoutes` registration (after line 36): - - ```typescript - await fastify.register(terminalWsRoutes) - ``` - -- [ ] **Step 6: Lint backend** - - ```bash - cd backend && npm run lint - ``` - Expected: no errors. - -- [ ] **Step 7: Commit** - - ```bash - git add backend/src/routes/sessions.ts backend/src/server.ts - git commit -m "feat: wire terminal WS route and mode into sessions API" - ``` - ---- - -## Task 4: SessionContext mode field - -**Files:** -- Modify: `frontend/src/context/SessionContext.tsx` - -- [ ] **Step 1: Add `mode` to `SessionState`** - - In `frontend/src/context/SessionContext.tsx`, replace the `SessionState` interface (lines 15-26): - - ```typescript - export interface SessionState { - sessionId: string | null - workdir: string | null - name: string | null - model: string | null - mode: 'chat' | 'terminal' - wsState: 'disconnected' | 'connecting' | 'running' | 'idle' | 'error' - messages: Message[] - workingTimeMs: number - runningStartedAt: number | null - totalTokens: number - pendingPermissions: PermissionRequest[] | null - } - ``` - -- [ ] **Step 2: Add `mode` to `SESSION_CREATED` and `RESUME_SESSION` action types** - - Replace the `Action` type definition (lines 28-41): - - ```typescript - type Action = - | { type: 'SESSION_CREATED'; sessionId: string; workdir: string; name?: string; mode: 'chat' | 'terminal' } - | { type: 'SESSION_CLEARED' } - | { type: 'RESUME_SESSION'; id: string; workdir: string; name?: string; mode: 'chat' | 'terminal' } - | { type: 'WS_STATE'; state: SessionState['wsState']; timestamp: number } - | { type: 'MESSAGE_ADDED'; message: Message } - | { type: 'HISTORY_LOADED'; messages: Message[] } - | { type: 'MODEL_SET'; model: string } - | { type: 'SESSION_RENAMED'; name: string | null } - | { type: 'TOKENS_ADDED'; inputTokens: number; outputTokens: number } - | { type: 'STATS_RESTORED'; totalTokens: number; workingTimeMs: number } - | { type: 'PERMISSION_REQUEST'; permissions: PermissionRequest[] } - | { type: 'PERMISSION_CLEARED' } - ``` - -- [ ] **Step 3: Add `mode` to the initial state** - - Replace the `initial` constant (lines 43-53): - - ```typescript - const initial: SessionState = { - sessionId: null, - workdir: null, - name: null, - model: null, - mode: 'chat', - wsState: 'disconnected', - messages: [], - workingTimeMs: 0, - runningStartedAt: null, - totalTokens: 0, - pendingPermissions: null, - } - ``` - -- [ ] **Step 4: Update reducer cases for `SESSION_CREATED` and `RESUME_SESSION`** - - Replace the two reducer cases (lines 57-62): - - ```typescript - case 'SESSION_CREATED': - return { ...state, sessionId: action.sessionId, workdir: action.workdir, name: action.name ?? null, mode: action.mode, messages: [], wsState: 'connecting', workingTimeMs: 0, runningStartedAt: null, pendingPermissions: null } - case 'SESSION_CLEARED': - return { ...initial } - case 'RESUME_SESSION': - return { ...state, sessionId: action.id, workdir: action.workdir, name: action.name ?? null, mode: action.mode, messages: [], wsState: 'connecting', workingTimeMs: 0, runningStartedAt: null, totalTokens: 0, pendingPermissions: null } - ``` - -- [ ] **Step 5: Lint frontend** - - ```bash - cd frontend && npm run lint - ``` - Expected: errors about callers of `SESSION_CREATED`/`RESUME_SESSION` missing `mode` — these will be fixed in Task 9. The important thing is no errors in `SessionContext.tsx` itself. - - > Note: TypeScript errors in `DashboardView.tsx` about missing `mode` property are expected at this stage. They confirm the type change propagated correctly and will be resolved in Task 9. - -- [ ] **Step 6: Commit** - - ```bash - git add frontend/src/context/SessionContext.tsx - git commit -m "feat: add mode field to SessionContext state and actions" - ``` - ---- - -## Task 5: useDashboard — Session type + settings fetch - -**Files:** -- Modify: `frontend/src/hooks/useDashboard.ts` - -- [ ] **Step 1: Add `mode` to the `Session` interface and add settings to the fetch** - - Replace the entire `useDashboard.ts` file: - - ```typescript - import { useState, useEffect, useCallback } from 'react' - import type { AccountInfo } from './useAccount' - import type { UsageData } from './useUsage' - - export interface Session { - id: string - workdir: string - name: string | null - model: string | null - started_at: number - ended_at: number | null - is_active: boolean - message_count: number - mode: 'chat' | 'terminal' - } - - export interface DashboardData { - account: AccountInfo | null - usage: UsageData | null - sessions: Session[] - activeSessions: number - defaultSessionMode: 'chat' | 'terminal' - loading: boolean - error: string | null - } - - export function useDashboard(month?: string): DashboardData & { refresh: () => void } { - const [account, setAccount] = useState(null) - const [usage, setUsage] = useState(null) - const [sessions, setSessions] = useState([]) - const [defaultSessionMode, setDefaultSessionMode] = useState<'chat' | 'terminal'>('chat') - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const target = month ?? new Date().toISOString().slice(0, 7) - - const fetchAll = useCallback(() => { - setLoading(true) - setError(null) - - Promise.all([ - fetch('/api/account').then((r) => r.json() as Promise), - fetch(`/api/usage?month=${target}`).then((r) => r.json() as Promise), - fetch('/api/sessions').then((r) => r.json() as Promise), - fetch('/api/settings').then((r) => r.json() as Promise>), - ]) - .then(([acc, usg, sess, settings]) => { - setAccount(acc) - setUsage(usg) - setSessions(Array.isArray(sess) ? sess : []) - setDefaultSessionMode(settings.session_mode === 'terminal' ? 'terminal' : 'chat') - setLoading(false) - }) - .catch((e: Error) => { - setError(e.message) - setLoading(false) - }) - }, [target]) - - useEffect(() => { - fetchAll() - const timer = setInterval(fetchAll, 60_000) - return () => clearInterval(timer) - }, [fetchAll]) - - const activeSessions = sessions.filter((s) => s.is_active).length - - return { account, usage, sessions, activeSessions, defaultSessionMode, loading, error, refresh: fetchAll } - } - ``` - -- [ ] **Step 2: Lint frontend** - - ```bash - cd frontend && npm run lint - ``` - Expected: same `DashboardView.tsx` errors from Task 4 (still missing `mode` in dispatches). No new errors. - -- [ ] **Step 3: Commit** - - ```bash - git add frontend/src/hooks/useDashboard.ts - git commit -m "feat: add mode to Session type and fetch settings in useDashboard" - ``` - ---- - -## Task 6: useWebSocket mode guard - -**Files:** -- Modify: `frontend/src/hooks/useWebSocket.ts` - -- [ ] **Step 1: Add `state.mode` guard to prevent chat WS connecting in terminal mode** - - In `frontend/src/hooks/useWebSocket.ts`, replace lines 12-14 (the start of the useEffect): - - ```typescript - useEffect(() => { - if (!state.sessionId || state.mode === 'terminal') return - let closed = false - ``` - - Also update the dependency array at line 95 to include `state.mode`: - - ```typescript - }, [state.sessionId, state.mode]) // eslint-disable-line react-hooks/exhaustive-deps - ``` - -- [ ] **Step 2: Lint frontend** - - ```bash - cd frontend && npm run lint - ``` - Expected: same existing `DashboardView.tsx` errors only. No new errors. - -- [ ] **Step 3: Commit** - - ```bash - git add frontend/src/hooks/useWebSocket.ts - git commit -m "feat: skip chat WS connection when session mode is terminal" - ``` - ---- - -## Task 7: useTerminalSession hook - -**Files:** -- Create: `frontend/src/hooks/useTerminalSession.ts` - -- [ ] **Step 1: Create `frontend/src/hooks/useTerminalSession.ts`** - - ```typescript - import { useEffect, useRef, useCallback } from 'react' - import { useSession } from '../context/SessionContext' - - const MAX_RECONNECT_ATTEMPTS = 5 - const BASE_DELAY_MS = 500 - - export function useTerminalSession(onOutput: (data: string) => void) { - const { state, dispatch } = useSession() - const wsRef = useRef(null) - const attemptsRef = useRef(0) - - useEffect(() => { - if (!state.sessionId || state.mode !== 'terminal') return - let closed = false - - function connect() { - const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' - const host = window.location.host - const ws = new WebSocket(`${protocol}://${host}/ws/terminal/${state.sessionId}`) - wsRef.current = ws - dispatch({ type: 'WS_STATE', timestamp: Date.now(), state: 'connecting' }) - - ws.onopen = () => { - attemptsRef.current = 0 - } - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data as string) as { - type: string - data?: string - state?: string - } - if (msg.type === 'output' && msg.data) { - onOutput(msg.data) - } else if (msg.type === 'status') { - if (msg.state === 'connected') { - dispatch({ type: 'WS_STATE', timestamp: Date.now(), state: 'idle' }) - } else if (msg.state === 'disconnected' || msg.state === 'error') { - dispatch({ type: 'WS_STATE', timestamp: Date.now(), state: 'disconnected' }) - } - } - } catch { - // ignore malformed frames - } - } - - ws.onerror = () => dispatch({ type: 'WS_STATE', timestamp: Date.now(), state: 'error' }) - - ws.onclose = () => { - if (closed) return - if (attemptsRef.current < MAX_RECONNECT_ATTEMPTS) { - const delay = BASE_DELAY_MS * 2 ** attemptsRef.current - attemptsRef.current++ - setTimeout(connect, delay) - } else { - dispatch({ type: 'WS_STATE', timestamp: Date.now(), state: 'disconnected' }) - setTimeout(() => { - if (!closed) { attemptsRef.current = 0; connect() } - }, 30_000) - } - } - } - - connect() - return () => { - closed = true - attemptsRef.current = 0 - wsRef.current?.close() - wsRef.current = null - } - }, [state.sessionId, state.mode]) // eslint-disable-line react-hooks/exhaustive-deps - - const send = useCallback((payload: object) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify(payload)) - } - }, []) - - return { send } - } - ``` - -- [ ] **Step 2: Lint frontend** - - ```bash - cd frontend && npm run lint - ``` - Expected: same existing `DashboardView.tsx` errors only. - -- [ ] **Step 3: Commit** - - ```bash - git add frontend/src/hooks/useTerminalSession.ts - git commit -m "feat: add useTerminalSession hook for /ws/terminal/:id" - ``` - ---- - -## Task 8: TerminalSession component - -**Files:** -- Create: `frontend/src/components/TerminalSession.tsx` - -- [ ] **Step 1: Create `frontend/src/components/TerminalSession.tsx`** - - ```typescript - import { useEffect, useRef, useCallback } from 'react' - import { Terminal } from '@xterm/xterm' - import { FitAddon } from '@xterm/addon-fit' - import '@xterm/xterm/css/xterm.css' - import { useTerminalSession } from '../hooks/useTerminalSession' - - export default function TerminalSession() { - const containerRef = useRef(null) - const termRef = useRef(null) - const fitRef = useRef(null) - - const onOutput = useCallback((data: string) => { - termRef.current?.write(data) - }, []) - - const { send } = useTerminalSession(onOutput) - - useEffect(() => { - const term = new Terminal({ - theme: { - background: '#050505', - foreground: '#e2e2e2', - cursor: '#d97706', - selectionBackground: '#d9770640', - }, - fontFamily: 'JetBrains Mono, Fira Code, monospace', - fontSize: 12, - lineHeight: 1.4, - cursorBlink: true, - }) - const fit = new FitAddon() - term.loadAddon(fit) - termRef.current = term - fitRef.current = fit - - if (containerRef.current) { - term.open(containerRef.current) - requestAnimationFrame(() => fit.fit()) - term.onResize(({ cols, rows }) => send({ type: 'resize', cols, rows })) - term.onData((data) => send({ type: 'input', data })) - } - - return () => term.dispose() - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - // Re-fit terminal when container dimensions change (window resize, panel resize) - useEffect(() => { - if (!containerRef.current) return - const observer = new ResizeObserver(() => { - requestAnimationFrame(() => fitRef.current?.fit()) - }) - observer.observe(containerRef.current) - return () => observer.disconnect() - }, []) - - return ( -
-
-
- ) - } - ``` - -- [ ] **Step 2: Lint frontend** - - ```bash - cd frontend && npm run lint - ``` - Expected: same existing `DashboardView.tsx` errors only. No errors in `TerminalSession.tsx`. - -- [ ] **Step 3: Commit** - - ```bash - git add frontend/src/components/TerminalSession.tsx - git commit -m "feat: add full-height TerminalSession component" - ``` - ---- - -## Task 9: DashboardView wiring - -**Files:** -- Modify: `frontend/src/views/DashboardView.tsx` - -- [ ] **Step 1: Import `TerminalSession` and destructure `defaultSessionMode`** - - Add the import at the top of `DashboardView.tsx` (after the existing imports, before line 17): - - ```typescript - import TerminalSession from '../components/TerminalSession' - ``` - - Update the `useDashboard` destructure (line 23) to include `defaultSessionMode`: - - ```typescript - const { account, usage, sessions, activeSessions, loading, refresh, defaultSessionMode } = useDashboard() - ``` - -- [ ] **Step 2: Add `mode` to `handleSessionStart` dispatch** - - Replace `handleSessionStart` (lines 54-59): - - ```typescript - function handleSessionStart(sessionId: string, workdir: string, name: string | null) { - dispatch({ type: 'SESSION_CREATED', sessionId, workdir, ...(name ? { name } : {}), mode: defaultSessionMode }) - if (account?.model) dispatch({ type: 'MODEL_SET', model: account.model }) - setShowModal(false) - refresh() - } - ``` - -- [ ] **Step 3: Add `mode` to `handleResume` dispatch** - - Replace `handleResume` (lines 97-100): - - ```typescript - function handleResume(session: Session) { - dispatch({ type: 'RESUME_SESSION', id: session.id, workdir: session.workdir, ...(session.name ? { name: session.name } : {}), mode: session.mode ?? 'chat' }) - if (account?.model) dispatch({ type: 'MODEL_SET', model: account.model }) - } - ``` - -- [ ] **Step 4: Guard the PTY resize registration for chat mode only** - - Replace the resize `useEffect` (lines 47-51): - - ```typescript - useEffect(() => { - if (state.mode === 'terminal') return - terminalRef.current?.sendResize((cols, rows) => { - send({ type: 'resize', cols, rows }) - }) - }, [send, state.mode]) - ``` - -- [ ] **Step 5: Swap session content area based on `state.mode`** - - Replace the inner session layout (lines 162-185) — the block between `
` and its closing tag: - - ```tsx - {state.sessionId ? ( -
- {state.pendingPermissions && ( - - )} - - {state.mode === 'terminal' ? ( - - ) : ( - <> - - - - - )} -
- ) : showModal ? ( - - ) : ( - setShowModal(true)} - /> - )} - ``` - -- [ ] **Step 6: Lint frontend — should now be clean** - - ```bash - cd frontend && npm run lint - ``` - Expected: **no errors**. This is the step where all previously expected TypeScript errors resolve. - -- [ ] **Step 7: Commit** - - ```bash - git add frontend/src/views/DashboardView.tsx - git commit -m "feat: wire terminal mode into DashboardView session layout" - ``` - ---- - -## Task 10: SettingsView session mode toggle - -**Files:** -- Modify: `frontend/src/views/SettingsView.tsx` - -- [ ] **Step 1: Add `sessionMode` state and load it from settings** - - In `frontend/src/views/SettingsView.tsx`, add the new state variable after the `bypassPermissions` state (line 9): - - ```typescript - const [sessionMode, setSessionMode] = useState<'chat' | 'terminal'>('chat') - ``` - - In the `useEffect` fetch callback, add parsing for `session_mode` (after the `setBypassPermissions` line): - - ```typescript - .then((data) => { - setBypassPermissions(data.bypass_permissions !== 'false') - setSessionMode(data.session_mode === 'terminal' ? 'terminal' : 'chat') - setSettingsLoaded(true) - }) - ``` - -- [ ] **Step 2: Include `session_mode` in `handleSave`** - - Replace the `await fetch('/api/settings', ...)` call in `handleSave`: - - ```typescript - await fetch('/api/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - bypass_permissions: String(bypassPermissions), - session_mode: sessionMode, - }), - }).catch(() => {}) - ``` - -- [ ] **Step 3: Add the Session Mode toggle UI** - - Add this block in the JSX, after the "Tool Permissions" section and before the Save button: - - ```tsx - {/* Session mode toggle */} -
- -

- Chat mode shows a structured conversation with a collapsible terminal drawer. Terminal mode replaces the chat view with a full interactive terminal — interact with Claude Code exactly as you would in your local terminal. -

- -
- ``` - -- [ ] **Step 4: Lint frontend** - - ```bash - cd frontend && npm run lint - ``` - Expected: no errors. - -- [ ] **Step 5: Full lint check (both packages)** - - ```bash - make lint - ``` - Expected: no errors in either package. - -- [ ] **Step 6: Commit** - - ```bash - git add frontend/src/views/SettingsView.tsx - git commit -m "feat: add session mode toggle to SettingsView" - ``` - ---- - -## Verification - -Run `make dev` (backend on :9998, frontend on :9999) and verify: - -1. **Settings toggle**: Navigate to `/settings` — confirm "Session Mode" toggle appears alongside Tool Permissions, can be switched between chat/terminal, and saves correctly. - -2. **Chat mode (default)**: With mode=chat, create a new session — confirm existing chat layout (MessageList + TerminalDrawer + ChatInput) renders unchanged. - -3. **Terminal mode**: Switch to terminal mode, create a new session — confirm the session view shows only a full-height xterm.js terminal (no chat input, no message list, no terminal drawer header). - -4. **I/O passthrough**: In terminal mode, type a prompt into the terminal — confirm keystrokes appear in the terminal and Claude Code responds with ANSI-formatted output. - -5. **Resize**: Resize the browser window — confirm the terminal re-fits and PTY cols/rows update (visible when claude CLI redraws its prompt). - -6. **Stop session**: Click "Stop session" in the header while a terminal session is active — confirm PTY is killed, session shows as ended in the sessions list. - -7. **Session list + resume**: Both chat and terminal sessions appear in the sessions list. Resuming a terminal session reconnects to `/ws/terminal/:id`; resuming a chat session reconnects to `/ws/session/:id`. - -8. **Chat mode unaffected**: Switch back to chat mode, confirm the existing chat experience is identical to before. diff --git a/docs/specs/2026-05-12-terminal-mode-design.md b/docs/specs/2026-05-12-terminal-mode-design.md deleted file mode 100644 index 7c9824a..0000000 --- a/docs/specs/2026-05-12-terminal-mode-design.md +++ /dev/null @@ -1,160 +0,0 @@ -# Terminal Mode Design - -**Date:** 2026-05-12 -**Branch:** feat/terminal -**Status:** Approved - -## Context - -The session view currently shows only a chat interface (MessageList + TerminalDrawer drawer + ChatInput). The TerminalDrawer renders PTY output from the claude CLI spawned in non-interactive, structured-JSON mode (`--print --output-format stream-json`). There is no way to interact with the Claude Code CLI as a real terminal. - -This feature adds a global `session_mode` setting (`chat` | `terminal`) that switches the session view between the existing chat layout and a full terminal experience backed by a proper interactive PTY. - -## Approach - -Separate WebSocket endpoint (`/ws/terminal/:id`) alongside the existing `/ws/session/:id`. The two paths are fully independent — no changes to the existing chat session handler. Sessions carry their mode in the database so resuming always uses the correct endpoint. - -## Architecture - -``` -CHAT MODE TERMINAL MODE -───────────────────────────── ───────────────────────────── -POST /api/sessions POST /api/sessions - → mode: 'chat' stored in DB → mode: 'terminal' stored in DB - ↓ ↓ -WS /ws/session/:id WS /ws/terminal/:id (NEW) - → claude --print → claude (interactive PTY) - --output-format stream-json → raw I/O passthrough, no JSON - → structured message parsing → no parsing - ↓ ↓ -DashboardView DashboardView - → SessionHeader (shared) → SessionHeader (shared) - → MessageList → TerminalSession.tsx (NEW) - → TerminalDrawer (full-height xterm.js) - → ChatInput -``` - -## Backend Changes - -### `backend/src/routes/settings.ts` -Add `session_mode` to DEFAULTS and ALLOWED set: -```typescript -const DEFAULTS = { bypass_permissions: 'true', session_mode: 'chat' } -``` - -### `backend/src/db/schema.ts` -Add `mode` column to sessions table in CREATE TABLE statement: -```sql -mode TEXT NOT NULL DEFAULT 'chat' -``` -Add a migration guard at DB init time: -```sql -ALTER TABLE sessions ADD COLUMN mode TEXT DEFAULT 'chat' --- guarded by checking existing columns first -``` - -### `backend/src/routes/sessions.ts` -- **POST** `/api/sessions`: read `session_mode` from settings at creation time, store as `mode` on the new session row -- **GET** `/api/sessions`: include `mode` field in each session object returned -- **POST** `/api/sessions/:id/stop`: after existing SessionManager check, also call `terminalManager.stop(id)` to kill a running terminal PTY - -### `backend/src/ws/terminal.ts` (NEW, ~120 lines) - -`TerminalManager` singleton — tracks active terminal PTYs by session ID (mirrors the existing `SessionManager` pattern). - -WebSocket handler for `/ws/terminal/:id`: -- On connect: look up session from DB; spawn `claude` as persistent interactive PTY - - Args: `--dangerously-skip-permissions` when bypass_permissions=true, nothing else - - PTY config: `xterm-256color`, cols=220, rows=50, cwd=session workdir, inherits env -- Messages **in**: - - `{ type: 'input', data: string }` → write to PTY stdin - - `{ type: 'resize', cols: number, rows: number }` → resize PTY -- Messages **out**: - - `{ type: 'output', data: string }` → raw PTY bytes (ANSI preserved) - - `{ type: 'status', state: 'connected' | 'disconnected' }` -- On PTY exit: broadcast `status: disconnected`, mark session `ended_at` in DB -- Idle timeout: 30 minutes (same as chat sessions) - -### `backend/src/server.ts` -Register new WS route: `server.register(terminalWsPlugin)` alongside existing session WS. - -## Frontend Changes - -### `frontend/src/views/SettingsView.tsx` -Add a "Session Mode" toggle row using the same pattern as the `bypass_permissions` toggle. Two options: **Chat** (default) | **Terminal**. POSTs `{ session_mode: 'chat' | 'terminal' }` to `/api/settings` on change. Applies to all new sessions. - -### `frontend/src/context/SessionContext.tsx` -Add `mode: 'chat' | 'terminal'` to `SessionState`. Populate it via: -- `SESSION_CREATED` action (includes mode from global setting at creation time) -- `RESUME_SESSION` action (includes mode from the session's DB record) - -This is the source of truth for which UI to render — not the global setting directly. - -### `frontend/src/hooks/useDashboard.ts` -Extend the existing parallel fetch to also load `GET /api/settings`. Return `defaultSessionMode: 'chat' | 'terminal'` (used only when creating new sessions to pre-populate the mode). - -### `frontend/src/views/DashboardView.tsx` -Two targeted changes: -1. Pass `null` to `useWebSocket` when `state.mode === 'terminal'` to prevent the chat WS from connecting -2. Swap session content based on `state.mode` — SessionHeader is always rendered: -```tsx -{state.mode === 'terminal' - ? - : <> - - - - -} -``` - -### `frontend/src/hooks/useTerminalSession.ts` (NEW) -Mirrors `useWebSocket` structure but targets `/ws/terminal/:id`: -- Connects on mount when `sessionId` is set -- Sends `{ type: 'input', data }` and `{ type: 'resize', cols, rows }` -- On `status: connected` → dispatch `WS_STATE('idle')` to SessionContext -- On `status: disconnected` → dispatch `WS_STATE('disconnected')` -- On `output` → call `onOutput(data)` callback for xterm write -- Reconnect: exponential backoff up to 5 attempts, then 30s polling (same as useWebSocket) - -### `frontend/src/components/TerminalSession.tsx` (NEW) -Full-height xterm.js terminal filling the space between SessionHeader and viewport bottom: -- Same xterm theme and FitAddon config as existing `TerminalDrawer` -- All keystrokes → `type: 'input'` via useTerminalSession -- ResizeObserver on container → `type: 'resize'` -- `onOutput` callback writes raw data to xterm instance -- No MessageList, no ChatInput — the terminal IS the interface - -### `SessionHeader` (unchanged) -Works for both modes. In terminal mode naturally shows fewer stats (no tokens counter, no running spinner — status is connected/idle). Session name, workdir, stop button, new session button, rename all function identically. - -## Session Resumption - -Sessions carry `mode` in the DB. The session list (`GET /api/sessions`) returns `mode` per session. When `DashboardView.handleResume()` is called, it dispatches `RESUME_SESSION` with the session's stored `mode` — this updates `SessionContext.state.mode` and the correct UI renders automatically. The global setting (`defaultSessionMode`) only determines the mode stored when a **new** session is created; it does not affect resumed sessions. - -## Files to Create -- `backend/src/ws/terminal.ts` -- `frontend/src/hooks/useTerminalSession.ts` -- `frontend/src/components/TerminalSession.tsx` - -## Files to Modify -- `backend/src/routes/settings.ts` -- `backend/src/db/schema.ts` (or wherever migrations run) -- `backend/src/routes/sessions.ts` -- `backend/src/server.ts` -- `frontend/src/context/SessionContext.tsx` -- `frontend/src/hooks/useDashboard.ts` -- `frontend/src/views/DashboardView.tsx` -- `frontend/src/views/SettingsView.tsx` - -## Verification - -1. **Settings**: Open `/settings`, confirm new "Session Mode" toggle appears and saves (Chat ↔ Terminal) -2. **Terminal session creation**: Switch to Terminal mode, create a new session — confirm PTY spawns `claude` interactively, full terminal renders in session view -3. **Chat session creation**: Switch back to Chat mode, create a session — confirm existing chat layout is untouched -4. **I/O passthrough**: In terminal mode, type a prompt directly in the terminal, confirm response renders with ANSI formatting -5. **Resize**: Resize browser window, confirm PTY cols/rows update -6. **Stop**: Click Stop in session header while terminal is active — confirm PTY is killed, session marked ended -7. **Session list**: Confirm both chat and terminal sessions appear in the list and resume correctly -8. **Idle timeout**: Verify terminal PTY is killed after 30 min of inactivity (same as chat) -9. **Lint**: `make lint` passes in both packages diff --git a/frontend/index.html b/frontend/index.html index 2b04383..03dfa65 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,11 @@ - Claude Code Dashboard + + + + + Claude Code Web UI diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..b4b0b48 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..3bc82d8 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..1997f2c Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..a2cfb35 Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae5d095..236f72d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' -import DashboardView from './views/DashboardView' +import HomeView from './views/HomeView' import SessionRoute from './views/SessionRoute' import SettingsView from './views/SettingsView' import SessionsTableView from './views/SessionsTableView' @@ -12,7 +12,7 @@ export default function App() {
- } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/SessionList.tsx b/frontend/src/components/SessionList.tsx index 1d78762..3c0a11f 100644 --- a/frontend/src/components/SessionList.tsx +++ b/frontend/src/components/SessionList.tsx @@ -1,6 +1,6 @@ import { Plus, Play, Square, Trash2 } from 'lucide-react' import { Link } from 'react-router-dom' -import type { Session } from '../hooks/useDashboard' +import type { Session } from '../hooks/useHomeData' import { formatRelativeTime, lastSegment } from '../utils/format' interface SessionListProps { diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useHomeData.ts similarity index 95% rename from frontend/src/hooks/useDashboard.ts rename to frontend/src/hooks/useHomeData.ts index 7e571bb..ea95827 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useHomeData.ts @@ -29,7 +29,7 @@ export interface Session { last_used: number | null } -export interface DashboardData { +export interface HomeData { account: AccountInfo | null usage: UsageData | null sessions: Session[] @@ -39,7 +39,7 @@ export interface DashboardData { error: string | null } -export function useDashboard(month?: string): DashboardData & { refresh: () => void } { +export function useHomeData(month?: string): HomeData & { refresh: () => void } { const [account, setAccount] = useState(null) const [usage, setUsage] = useState(null) const [sessions, setSessions] = useState([]) diff --git a/frontend/src/views/DashboardView.tsx b/frontend/src/views/HomeView.tsx similarity index 98% rename from frontend/src/views/DashboardView.tsx rename to frontend/src/views/HomeView.tsx index 3044818..9ab4973 100644 --- a/frontend/src/views/DashboardView.tsx +++ b/frontend/src/views/HomeView.tsx @@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { ChevronDown, ChevronUp } from 'lucide-react' import { useSession } from '../context/SessionContext' import { useWebSocket } from '../hooks/useWebSocket' -import { useDashboard, type Session } from '../hooks/useDashboard' +import { useHomeData, type Session } from '../hooks/useHomeData' import StatsStrip from '../components/StatsStrip' import SessionList from '../components/SessionList' import SessionHeader from '../components/SessionHeader' @@ -15,7 +15,7 @@ import UsageChart from '../components/UsageChart' import PermissionDialog from '../components/PermissionDialog' import TerminalSession from '../components/TerminalSession' -export default function DashboardView() { +export default function HomeView() { const { state, dispatch } = useSession() const location = useLocation() const navigate = useNavigate() @@ -23,7 +23,7 @@ export default function DashboardView() { const [showModal, setShowModal] = useState(location.state?.openModal === true) const [chartOpen, setChartOpen] = useState(false) - const { account, usage, sessions, activeSessions, defaultSessionMode, loading, refresh } = useDashboard() + const { account, usage, sessions, activeSessions, defaultSessionMode, loading, refresh } = useHomeData() // Local sessions state for optimistic deletion const [localSessions, setLocalSessions] = useState(null) const displaySessions = localSessions ?? sessions diff --git a/frontend/src/views/SessionRoute.tsx b/frontend/src/views/SessionRoute.tsx index 26502ec..5ef6ad6 100644 --- a/frontend/src/views/SessionRoute.tsx +++ b/frontend/src/views/SessionRoute.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react' import { useParams, Navigate, useLocation, useNavigate } from 'react-router-dom' import { useSession } from '../context/SessionContext' -import DashboardView from './DashboardView' -import type { Session } from '../hooks/useDashboard' +import HomeView from './HomeView' +import type { Session } from '../hooks/useHomeData' export default function SessionRoute() { const { sessionId } = useParams<{ sessionId: string }>() @@ -47,7 +47,7 @@ export default function SessionRoute() { // Keep URL and context in sync: clear session state whenever this route unmounts. // This handles browser Back/Forward navigation, which bypasses the explicit navigate() // calls in the session handlers and would otherwise leave a stale sessionId in context, - // causing DashboardView at "/" to render the session view instead of the session list. + // causing HomeView at "/" to render the session view instead of the session list. useEffect(() => { return () => { dispatch({ type: 'SESSION_CLEARED' }) } }, [dispatch]) @@ -75,5 +75,5 @@ export default function SessionRoute() { ) } - return + return } diff --git a/frontend/src/views/SettingsView.tsx b/frontend/src/views/SettingsView.tsx index 1947bc1..36a0f93 100644 --- a/frontend/src/views/SettingsView.tsx +++ b/frontend/src/views/SettingsView.tsx @@ -9,6 +9,7 @@ export default function SettingsView() { const savedTimerRef = useRef | null>(null) const [statuslineUnmatched, setStatuslineUnmatched] = useState<'ignore' | 'create'>('ignore') const [settingsLoaded, setSettingsLoaded] = useState(false) + const [dbPath, setDbPath] = useState(null) useEffect(() => { return () => { if (savedTimerRef.current) clearTimeout(savedTimerRef.current) } @@ -24,6 +25,11 @@ export default function SettingsView() { setSettingsLoaded(true) }) .catch(() => setSettingsLoaded(true)) + + fetch('/api/system') + .then((r) => r.json() as Promise<{ db_path: string }>) + .then((data) => setDbPath(data.db_path)) + .catch(() => {}) }, []) async function handleSave() { @@ -56,7 +62,7 @@ export default function SettingsView() {
-
+
{/* Session mode toggle */}
) diff --git a/run.ps1 b/run.ps1 index 2de19fc..c309055 100644 --- a/run.ps1 +++ b/run.ps1 @@ -1,6 +1,32 @@ #Requires -Version 7.0 $ErrorActionPreference = 'Stop' +# Parse args manually so we accept the POSIX-style `--dev` the user typed +# on bash, while still honoring the PowerShell-native `-Dev`. +$Dev = $false +foreach ($arg in $args) { + switch ($arg) { + '--dev' { $Dev = $true } + '-Dev' { $Dev = $true } + { $_ -in '-h','--help','-?' } { + @" +Usage: run.ps1 [--dev] + + --dev Run in development mode with hot reload (skips build, + starts backend via 'tsx watch' on :9998 and Vite dev + server on :9999). + default Production mode: install, build, and serve the bundled + frontend from the backend on :PORT (default 9998). +"@ | Write-Host + exit 0 + } + default { + Write-Error "Unknown argument: $arg (use --help for usage)" + exit 1 + } + } +} + $RootDir = $PSScriptRoot # Bootstrap .env on first run @@ -27,15 +53,57 @@ Push-Location (Join-Path $RootDir 'backend') npm install Pop-Location -Write-Host 'Building...' -Push-Location (Join-Path $RootDir 'frontend') -npm run build -Pop-Location +if ($Dev) { + $Port = if ($env:PORT) { $env:PORT } else { '9998' } + Write-Host 'Starting Claude Code Web UI in DEV mode (hot reload)' + Write-Host " Backend → http://localhost:$Port (tsx watch)" + Write-Host " Frontend → http://localhost:9999 (Vite HMR)" -Push-Location (Join-Path $RootDir 'backend') -npm run build -Pop-Location + # Resolve npm to an executable Start-Process can launch directly. + # On Windows, Get-Command npm often returns npm.ps1 (PowerShell-installed + # via the Node MSI), which Start-Process cannot execute without an + # explicit host. Explicitly resolve npm.cmd instead. On Unix, npm is a + # shell script with a shebang and runs fine as-is. + $npmPath = if ($IsWindows) { + (Get-Command npm.cmd -ErrorAction Stop).Source + } else { + (Get-Command npm -ErrorAction Stop).Source + } + + # Start both dev servers with shared console output. -PassThru gives us + # the Process object so we can kill the tree on exit. + $backend = Start-Process -FilePath $npmPath -ArgumentList 'run','dev' ` + -WorkingDirectory (Join-Path $RootDir 'backend') ` + -NoNewWindow -PassThru + $frontend = Start-Process -FilePath $npmPath -ArgumentList 'run','dev' ` + -WorkingDirectory (Join-Path $RootDir 'frontend') ` + -NoNewWindow -PassThru + + try { + # Wait until either process exits; if one dies, tear the other down. + while (-not $backend.HasExited -and -not $frontend.HasExited) { + Start-Sleep -Milliseconds 500 + } + } finally { + # `npm.cmd` spawns `node` as a child — Stop-Process on the wrapper + # leaves node orphaned, so we use taskkill /T to kill the tree. + foreach ($proc in @($backend, $frontend)) { + if ($proc -and -not $proc.HasExited) { + taskkill.exe /T /F /PID $proc.Id 2>$null | Out-Null + } + } + } +} else { + Write-Host 'Building...' + Push-Location (Join-Path $RootDir 'frontend') + npm run build + Pop-Location -$Port = if ($env:PORT) { $env:PORT } else { '9998' } -Write-Host "Starting Claude Code Dashboard → http://localhost:$Port" -node (Join-Path $RootDir 'backend\dist\server.js') + Push-Location (Join-Path $RootDir 'backend') + npm run build + Pop-Location + + $Port = if ($env:PORT) { $env:PORT } else { '9998' } + Write-Host "Starting Claude Code Web UI → http://localhost:$Port" + node (Join-Path $RootDir 'backend\dist\server.js') +} diff --git a/run.sh b/run.sh index 02a392c..de0e496 100755 --- a/run.sh +++ b/run.sh @@ -1,6 +1,30 @@ #!/usr/bin/env bash set -euo pipefail +DEV_MODE=0 +for arg in "$@"; do + case "$arg" in + --dev) DEV_MODE=1 ;; + -h|--help) + cat <&2 + echo "Run with --help for usage." >&2 + exit 1 + ;; + esac +done + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Bootstrap .env on first run @@ -19,10 +43,24 @@ echo "Installing dependencies..." cd "$ROOT_DIR/frontend" && npm install cd "$ROOT_DIR/backend" && npm install -echo "Building..." -cd "$ROOT_DIR/frontend" && npm run build -cd "$ROOT_DIR/backend" && npm run build +if [[ $DEV_MODE -eq 1 ]]; then + echo "Starting Claude Code Web UI in DEV mode (hot reload)" + echo " Backend → http://localhost:${PORT:-9998} (tsx watch)" + echo " Frontend → http://localhost:9999 (Vite HMR)" + + # Forward SIGINT/SIGTERM/EXIT to the whole process group so both + # children die when the user hits Ctrl+C. + trap 'trap - INT TERM EXIT; kill 0' INT TERM EXIT -PORT="${PORT:-9998}" -echo "Starting Claude Code Dashboard → http://localhost:${PORT}" -exec node "$ROOT_DIR/backend/dist/server.js" + (cd "$ROOT_DIR/backend" && npm run dev) & + (cd "$ROOT_DIR/frontend" && npm run dev) & + wait +else + echo "Building..." + cd "$ROOT_DIR/frontend" && npm run build + cd "$ROOT_DIR/backend" && npm run build + + PORT="${PORT:-9998}" + echo "Starting Claude Code Web UI → http://localhost:${PORT}" + exec node "$ROOT_DIR/backend/dist/server.js" +fi diff --git a/scripts/claude-code-dashboard.service.template b/scripts/claude-code-webui.service.template similarity index 68% rename from scripts/claude-code-dashboard.service.template rename to scripts/claude-code-webui.service.template index 41698e7..3c10f89 100644 --- a/scripts/claude-code-dashboard.service.template +++ b/scripts/claude-code-webui.service.template @@ -1,12 +1,12 @@ [Unit] -Description=Claude Code Dashboard +Description=Claude Code Web UI After=network.target [Service] Type=simple WorkingDirectory=__INSTALL_DIR__ ExecStart=__NODE_BIN__ __INSTALL_DIR__/backend/dist/server.js -EnvironmentFile=%h/.config/systemd/user/claude-code-dashboard.env +EnvironmentFile=%h/.config/systemd/user/claude-code-webui.env Restart=on-failure RestartSec=5 diff --git a/scripts/install-service.sh b/scripts/install-service.sh index b7f3598..eeba6d1 100755 --- a/scripts/install-service.sh +++ b/scripts/install-service.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTALL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" SYSTEMD_USER_DIR="$HOME/.config/systemd/user" -SERVICE_NAME="claude-code-dashboard" +SERVICE_NAME="claude-code-webui" ENV_FILE="$SYSTEMD_USER_DIR/$SERVICE_NAME.env" UNIT_FILE="$SYSTEMD_USER_DIR/$SERVICE_NAME.service" TEMPLATE="$SCRIPT_DIR/$SERVICE_NAME.service.template" @@ -87,6 +87,6 @@ loginctl enable-linger "$USER" || echo "Warning: could not enable linger — you PORT_ACTUAL=$PORT echo "" -echo "Done! Claude Code Dashboard is running at: http://localhost:${PORT_ACTUAL}" +echo "Done! Claude Code Web UI is running at: http://localhost:${PORT_ACTUAL}" echo "Check status : systemctl --user status $SERVICE_NAME" echo "View logs : journalctl --user -u $SERVICE_NAME -f" diff --git a/scripts/test-service-install.sh b/scripts/test-service-install.sh index 1deacab..f5e8aaa 100755 --- a/scripts/test-service-install.sh +++ b/scripts/test-service-install.sh @@ -2,7 +2,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TEMPLATE="$SCRIPT_DIR/claude-code-dashboard.service.template" +TEMPLATE="$SCRIPT_DIR/claude-code-webui.service.template" PASS=0 FAIL=0 @@ -40,13 +40,13 @@ trap 'rm -rf "$TMPDIR"' EXIT echo "=== Unit file template substitution ===" sed \ - "s|__INSTALL_DIR__|/opt/claude-dashboard|g; s|__NODE_BIN__|/usr/bin/node|g" \ + "s|__INSTALL_DIR__|/opt/claude-code-webui|g; s|__NODE_BIN__|/usr/bin/node|g" \ "$TEMPLATE" > "$TMPDIR/test.service" UNIT=$(cat "$TMPDIR/test.service") -assert_contains "WorkingDirectory set" "WorkingDirectory=/opt/claude-dashboard" "$UNIT" -assert_contains "ExecStart node path" "ExecStart=/usr/bin/node /opt/claude-dashboard/backend/dist/server.js" "$UNIT" -assert_contains "EnvironmentFile uses %h" "EnvironmentFile=%h/.config/systemd/user/claude-code-dashboard.env" "$UNIT" +assert_contains "WorkingDirectory set" "WorkingDirectory=/opt/claude-code-webui" "$UNIT" +assert_contains "ExecStart node path" "ExecStart=/usr/bin/node /opt/claude-code-webui/backend/dist/server.js" "$UNIT" +assert_contains "EnvironmentFile uses %h" "EnvironmentFile=%h/.config/systemd/user/claude-code-webui.env" "$UNIT" assert_contains "Restart=on-failure" "Restart=on-failure" "$UNIT" assert_contains "WantedBy=default.target" "WantedBy=default.target" "$UNIT" assert_not_contains "no __INSTALL_DIR__ remaining" "__INSTALL_DIR__" "$UNIT" diff --git a/scripts/uninstall-service.sh b/scripts/uninstall-service.sh index 4700630..2d8ed2d 100755 --- a/scripts/uninstall-service.sh +++ b/scripts/uninstall-service.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SERVICE_NAME="claude-code-dashboard" +SERVICE_NAME="claude-code-webui" SYSTEMD_USER_DIR="$HOME/.config/systemd/user" ENV_FILE="$SYSTEMD_USER_DIR/$SERVICE_NAME.env" UNIT_FILE="$SYSTEMD_USER_DIR/$SERVICE_NAME.service"