diff --git a/CLAUDE.md b/CLAUDE.md index 8b6ee99..fb216d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,13 @@ pnpm build # builds all three; shared builds first (topological) pnpm typecheck # tsc --noEmit across all packages (svelte-check for web) pnpm test # runs each package's test (only server has real tests) pnpm dev # server (:3000) + web Vite (:5173) in parallel +pnpm lint # eslint . (flat config in eslint.config.js; extends prettier) +pnpm format # prettier --write . (format:check is the CI gate) +pnpm start # node server/dist/index.js (serves built web too; run after build) ``` +CI (`.github/workflows`) gates on `format:check`, `lint`, `typecheck`, `build`, and `test`; run them locally before pushing. + Important ordering: `server` and `web` import from `@hub/shared`'s built `dist/`. Before `pnpm dev`, build shared at least once (`pnpm --filter @hub/shared build`), or run it in watch (`pnpm --filter @hub/shared dev`). A stale `shared/dist` is the usual cause of phantom type errors after editing shared. Server-specific (run from repo root): @@ -41,7 +46,7 @@ The spine is: **engine adapter normalizes a CLI stream into `NormalizedEvent`s - - `shared/` (`@hub/shared`) is the contract package: the `NormalizedEvent` schema, the WS `ClientMsg`/`ServerMsg` protocol, and REST DTOs. Both other packages depend on it. Change a type here and rebuild before the change is visible downstream. - `server/` (`@hub/server`) is Fastify + `ws`, backed by better-sqlite3. -- `web/` (`@hub/web`) is a Svelte 5 SPA (no SvelteKit): plain Vite, markdown via `marked` + `DOMPurify` + `highlight.js`. +- `web/` (`@hub/web`) is a Svelte 5 SPA (no SvelteKit): plain Vite, markdown via `marked` + `DOMPurify` + `highlight.js`. Styling is dependency-free: semantic design tokens in `web/src/app.css` (`--surface-*`, `--status-*`, spacing/type/radius/motion scales; a light theme would be a `[data-theme]` override block) plus scoped component styles keyed off `data-*` attributes. Shared primitives live in `web/src/components/ui/` (`Icon` inline-SVG set, `Toasts` + `lib/toasts.ts` with severities/undo actions, `ConfirmDialog` + `lib/confirm.ts` promise API over native ``, `ShortcutsHelp` + `lib/shortcuts.ts` global hotkeys). The sidebar collapses to an overlay drawer at <= 900px (drawer state owned by `App.svelte`). Offline behavior: `ws.ts send()` returns a boolean, sends are disabled while disconnected, and optimistic user messages live in `stores.ts pendingSends` (outside `eventsBySession`, reconciled by FIFO text match against the server echo; interrupt clears them with a restore toast). ### Event flow and key types @@ -56,6 +61,8 @@ The spine is: **engine adapter normalizes a CLI stream into `NormalizedEvent`s - - **Two state axes** (both on `SessionDto`): interaction `status` (idle/running/awaiting_approval/error) and engine liveness `engineState` (`new` -> `live` on spawn -> `hibernated` on idle-dispose/stop/boot-reconcile, or `dead` on an unexpected exit) plus `enginePid`. `stopEngine()` (WS `stop_engine`) tears the process down now while keeping the session resumable; a crash mid-turn also closes the open turn with a `done{error}`. - **Resume**: `engine_session_id` is updated from *every* engine `init` (`engine_meta`), not just the first, because some CLI versions mint a new session id per resume and only the latest resumes correctly. The hibernated/dead engine is respawned with `--resume ` on the next message. - **Fork** (`POST /api/sessions/:id/fork` -> `SessionManager.fork`): copies the parent's config + event log (`EventsRepo.copyEvents`) into a new session that seeds the parent's `engine_session_id` and sets a one-shot `fork_pending` flag (migration 4). On the fork's first `ensureRun` that flag adds `--fork-session` alongside `--resume `, so the CLI replays the parent transcript but writes into a *new* engine session id; `fork_pending` is consumed on the first `engine_meta`. The parent is untouched. Only a session that has run (non-null `engineSessionId`) is forkable. +- **Isolated git worktree** (the New Session launcher, `web/src/components/Launcher.svelte`): a `CreateSessionRequest` with `worktree: true` (+ optional base `branch`) makes `SessionManager.create` call `addWorktree` (`server/src/fs/git.ts`) to check out a fresh `hub/` branch into `/`, and runs the session there instead of the source repo, so its edits never touch the source working tree. `worktreeRoot` defaults to `/worktrees` (`WORKTREE_ROOT`). Requires a git-repo cwd; failures raise `HubError('worktree_failed')`. `delete` best-effort removes the worktree and its `hub/…` branch (only ever a hub-managed branch, never a user branch). The launcher populates its branch dropdown from `GET /api/git/info` (`gitInfo`, best-effort, never throws for a non-repo cwd). +- **Archive**: a session is soft-hidden via `PATCH /api/sessions/:id {archived:true}` (sets `archivedAt`); `GET /api/sessions` omits archived rows unless `?includeArchived=1`. Archive is a list-visibility flag only, it does not touch the engine or event log; `delete` is the hard removal (cascades events). ### Permission control (the "safety dial") @@ -80,7 +87,7 @@ The CLI's `permission_suggestions` (allow-rules it proposes for a prompt) ride t ### Transport -- **REST** (`server/src/api/routes.ts`): sessions CRUD, `/api/engines`, `/api/fs/{validate,browse}`, `/api/health`. Bearer-token auth via an `onRequest` hook (`health` exempt). `HubError` maps to status codes (`not_found` -> 404, `session_busy` -> 409, else 400). +- **REST** (`server/src/api/routes.ts`): sessions CRUD (`PATCH` toggles `archived`, `POST :id/fork`), `/api/engines`, `/api/fs/{validate,browse}`, `/api/git/info`, `/api/health`. Bearer-token auth via an `onRequest` hook (`health` exempt). `HubError` maps to status codes (`not_found` -> 404, `session_busy` -> 409, else 400, so `worktree_failed` -> 400). - **WebSocket** (`server/src/api/ws.ts`, protocol in `shared/src/protocol.ts`): one socket per tab at `/ws`, all sessions multiplexed. **Auth is by first message, never a URL token** (keeps it out of access logs). After auth, `subscribe {sessionId, afterEventId?, limit?}` replays persisted history (synchronous better-sqlite3 read, so no live event interleaves), then marks the subscription live. With `afterEventId` it is forward catch-up (full); omitted, it is a bounded initial load of the most recent `limit` events followed by a `replay_meta {hasMoreBefore, oldestEventId}` frame the client uses to page older history over REST (`/api/sessions/:id/events?beforeId&limit`). The web client (`web/src/lib/ws.ts`) reconnects with backoff and re-subscribes every known session from its last-seen event id. ### Working-directory safety @@ -89,6 +96,6 @@ The CLI's `permission_suggestions` (allow-rules it proposes for a prompt) ride t ## Configuration and deployment -Config is env-only (`server/src/config.ts`); see the table in `README.md` §10. Key vars: `AUTH_TOKEN` (generated + logged per boot if unset), `WORKSPACE_ROOTS` (colon-separated, default `$HOME`), `ENGINES` (default `claude-code,echo`), `CLAUDE_BIN`, `ENGINE_IDLE_TIMEOUT_MS`, `APPROVAL_TIMEOUT_MS` (0 = wait forever). `deploy/` holds a Dockerfile (pins the claude CLI version via ARG), docker-compose (mounts host `~/.claude` + `~/.claude.json` for auth; the platform stores no keys), and a systemd unit. +Config is env-only (`server/src/config.ts`); see the table in `README.md` §10. Key vars: `AUTH_TOKEN` (generated + logged per boot if unset), `WORKSPACE_ROOTS` (colon-separated, default `$HOME`), `ENGINES` (default `claude-code,echo`), `CLAUDE_BIN`, `ENGINE_IDLE_TIMEOUT_MS`, `APPROVAL_TIMEOUT_MS` (0 = wait forever), `DATA_DIR` (SQLite + default worktree parent, default `./data`), `WORKTREE_ROOT` (isolated-worktree parent, default `/worktrees`). `deploy/` holds a Dockerfile (pins the claude CLI version via ARG), docker-compose (mounts host `~/.claude` + `~/.claude.json` for auth; the platform stores no keys), and a systemd unit. Keep the workspace mount path stable across deploys: the claude CLI keys its transcript store by project path, so moving it breaks session resume. When bumping the pinned CLI version, run `pnpm --filter @hub/server spike` first and diff the output against `server/test/fixtures/*.ndjson` (captured from a known-good live run; `normalize.test.ts` asserts against them). diff --git a/web/src/App.svelte b/web/src/App.svelte index 1596d26..6b49245 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,34 +1,146 @@ + + {#if !$token} {:else} -
- -
- {#if $activeSessionId && $sessions.some((s) => s.id === $activeSessionId)} - - {:else} - +
+ {#if $connState !== 'online'} +
+ {$connState === 'connecting' ? 'Reconnecting…' : 'Offline'} + +
+ {/if} +
+ {#if isNarrow && sidebarOpen} + {/if} -
+ +
+ {#if isNarrow} +
+ + {activeSession?.title ?? 'New session'} +
+ {/if} + {#if activeSession} +
+ {:else} +
+ {/if} +
+
{/if} -{#if toast} - -{/if} + + + diff --git a/web/src/app.css b/web/src/app.css index e08d330..a46668d 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,18 +1,95 @@ +/* --------------------------------------------------------------------------- + * Design tokens. Two layers: + * - semantic tokens (below) are what components use; + * - a future light theme is a `[data-theme='light']` block overriding them. + * Legacy aliases at the bottom keep unmigrated components working and are + * removed once every component reads the semantic names. + * ------------------------------------------------------------------------- */ :root { - --bg: #0f1115; - --bg-panel: #161a21; - --bg-raised: #1d232d; - --bg-hover: #232a36; - --border: #2a3240; - --text: #d7dde6; - --text-dim: #8b95a5; + color-scheme: dark; + + /* surfaces */ + --surface-base: #0f1115; + --surface-panel: #161a21; + --surface-raised: #1d232d; + --surface-hover: #232a36; + --surface-sunken: #0b0d11; + + /* borders */ + --border-default: #2a3240; + --border-strong: #3a4557; + --border-focus: var(--accent); + + /* text */ + --text-primary: #d7dde6; + --text-secondary: #97a1b1; + --text-on-accent: #eaf1fb; + + /* accent + status */ --accent: #6ea8fe; - --accent-dim: #2c4a77; - --green: #4ade80; - --amber: #fbbf24; - --red: #f87171; - --mono: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace; - --sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --accent-muted: #2c4a77; + --status-success: #4ade80; + --status-warning: #fbbf24; + --status-danger: #f87171; + --status-info: var(--accent); + --status-success-surface: color-mix(in srgb, var(--status-success) 8%, var(--surface-panel)); + --status-warning-surface: color-mix(in srgb, var(--status-warning) 8%, var(--surface-panel)); + --status-danger-surface: color-mix(in srgb, var(--status-danger) 8%, var(--surface-panel)); + + /* spacing (4px scale) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + /* type scale */ + --font-size-xs: 11px; + --font-size-sm: 12.5px; + --font-size-md: 14px; + --font-size-lg: 16px; + --font-size-xl: 20px; + --font-size-2xl: 26px; + --leading-tight: 1.3; + --leading-normal: 1.55; + --font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace; + --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + + /* radii */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 12px; + --radius-full: 999px; + + /* focus ring */ + --focus-ring-color: var(--accent); + --focus-ring-width: 2px; + --focus-ring-offset: 1px; + + /* motion */ + --duration-fast: 120ms; + --duration-normal: 200ms; + --ease-out: cubic-bezier(0.2, 0, 0, 1); + + /* elevation + layers */ + --shadow-overlay: 0 8px 32px rgb(0 0 0 / 0.5); + --z-drawer: 40; + --z-dialog: 50; + --z-toast: 60; + + /* layout */ + --sidebar-width: 290px; + + /* code highlighting (highlight.js) */ + --code-keyword: #c678dd; + --code-string: #98c379; + --code-number: #d19a66; + --code-title: #61afef; + --code-comment: #7f848e; + --code-variable: #e06c75; } * { @@ -23,35 +100,66 @@ html, body { margin: 0; height: 100%; - background: var(--bg); - color: var(--text); - font-family: var(--sans); - font-size: 14px; + background: var(--surface-base); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: var(--font-size-md); } #app { height: 100%; } +/* Track the real visual viewport on mobile (URL bar show/hide). */ +@supports (height: 100dvh) { + body, + #app { + height: 100dvh; + } +} + +/* Visible focus for keyboard users, on everything. */ +:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} button { font: inherit; color: inherit; - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: 6px; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); padding: 6px 12px; cursor: pointer; + transition: + background var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out); } button:hover { - background: var(--bg-hover); + background: var(--surface-hover); } button.primary { - background: var(--accent-dim); + background: var(--accent-muted); border-color: var(--accent); } button.danger { - border-color: var(--red); - color: var(--red); + border-color: var(--status-danger); + color: var(--status-danger); +} +/* Borderless icon-button base: components add their own sizing. */ +button.quiet { + background: none; + border: none; + padding: 4px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; +} +button.quiet:hover { + background: var(--surface-hover); + color: var(--text-primary); } button:disabled { opacity: 0.5; @@ -62,36 +170,50 @@ input, select, textarea { font: inherit; - color: var(--text); - background: var(--bg); - border: 1px solid var(--border); - border-radius: 6px; + color: var(--text-primary); + background: var(--surface-base); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); padding: 8px 10px; } -input:focus, -select:focus, -textarea:focus { - outline: none; - border-color: var(--accent); +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: 0; + border-color: var(--border-focus); +} + +/* Visually hidden but readable by assistive tech. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .markdown { - line-height: 1.55; + line-height: var(--leading-normal); overflow-wrap: break-word; } .markdown pre { - background: #0b0d11; - border: 1px solid var(--border); - border-radius: 8px; + background: var(--surface-sunken); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); padding: 10px 12px; overflow-x: auto; - font-family: var(--mono); - font-size: 12.5px; + font-family: var(--font-mono); + font-size: var(--font-size-sm); } .markdown code { - font-family: var(--mono); + font-family: var(--font-mono); font-size: 0.92em; - background: #0b0d11; + background: var(--surface-sunken); border-radius: 4px; padding: 1px 5px; } @@ -100,10 +222,10 @@ textarea:focus { padding: 0; } .markdown blockquote { - border-left: 3px solid var(--border); + border-left: 3px solid var(--border-default); margin: 6px 0; padding: 2px 12px; - color: var(--text-dim); + color: var(--text-secondary); } .markdown p { margin: 0.4em 0; @@ -118,7 +240,7 @@ textarea:focus { } .markdown th, .markdown td { - border: 1px solid var(--border); + border: 1px solid var(--border-default); padding: 4px 10px; } @@ -127,30 +249,49 @@ textarea:focus { .hljs-selector-tag, .hljs-literal, .hljs-section { - color: #c678dd; + color: var(--code-keyword); } .hljs-string, .hljs-attr, .hljs-template-tag { - color: #98c379; + color: var(--code-string); } .hljs-number, .hljs-symbol, .hljs-bullet { - color: #d19a66; + color: var(--code-number); } .hljs-title, .hljs-name, .hljs-type { - color: #61afef; + color: var(--code-title); } .hljs-comment, .hljs-quote, .hljs-meta { - color: #7f848e; + color: var(--code-comment); font-style: italic; } .hljs-variable, .hljs-regexp { - color: #e06c75; + color: var(--code-variable); +} + +/* Comfortable touch targets on coarse pointers. */ +@media (pointer: coarse) { + button.quiet { + min-width: 28px; + min-height: 28px; + } +} + +/* Respect reduced-motion: stop continuous animation and instant transitions. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } diff --git a/web/src/components/ApprovalCard.svelte b/web/src/components/ApprovalCard.svelte index 022267a..9acf0d6 100644 --- a/web/src/components/ApprovalCard.svelte +++ b/web/src/components/ApprovalCard.svelte @@ -1,7 +1,13 @@
@@ -151,7 +153,7 @@
{#each recentProjects as p (p)} {/each}
@@ -173,39 +175,69 @@
{#if validation} {#if validation.ok} - ✓ {validation.resolved}{validation.isGitRepo ? ' · git repo' : ''} + + + {validation.resolved}{validation.isGitRepo ? ' · git repo' : ''} + {:else} - ✗ {validation.error} + {validation.error} {/if} + {:else if validationError} + {validationError} {/if} {#if showBrowse && browseData} -
+ + +
{ + if (e.key === 'Escape') { + e.stopPropagation(); + showBrowse = false; + } + }} + >
{#if browseData.parent !== null || browseData.path} openBrowse(browseData?.parent ?? undefined)} > + + {/if} - {browseData.path || '(workspace roots)'} + {browseData.path || '(workspace roots)'} +
{#each browseData.entries as entry (entry.path)}
Select
{:else} -

no subdirectories

+

No subdirectories.

{/each}
{#if browseData.path} @@ -252,8 +284,16 @@
- {#if showAdvanced}
@@ -334,7 +374,7 @@ } .sub { margin: 0; - color: var(--text-dim); + color: var(--text-secondary); font-size: 13px; } .field { @@ -344,7 +384,7 @@ } .field-label { font-size: 12px; - color: var(--text-dim); + color: var(--text-secondary); } .row { display: flex; @@ -356,14 +396,21 @@ .hint { font-size: 12px; } + .hint.ok, + .hint.bad { + display: inline-flex; + align-items: center; + gap: 4px; + overflow-wrap: anywhere; + } .hint.dim { - color: var(--text-dim); + color: var(--text-secondary); } .ok { - color: var(--green); + color: var(--status-success); } .bad { - color: var(--red); + color: var(--status-danger); } .recent { display: flex; @@ -379,18 +426,18 @@ font-size: 12px; padding: 4px 10px; border-radius: 999px; - background: var(--bg-raised); - border: 1px solid var(--border); + background: var(--surface-raised); + border: 1px solid var(--border-default); } .cta { - border: 1px solid var(--amber); + border: 1px solid var(--status-warning); border-radius: 8px; padding: 10px 12px; font-size: 13px; - background: color-mix(in srgb, var(--amber) 6%, var(--bg-panel)); + background: color-mix(in srgb, var(--status-warning) 6%, var(--surface-panel)); } .git { - border: 1px solid var(--border); + border: 1px solid var(--border-default); border-radius: 8px; padding: 10px 12px; gap: 8px; @@ -415,22 +462,32 @@ } .disclosure { align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 3px; background: none; border: none; - color: var(--text-dim); + color: var(--text-secondary); padding: 2px 0; font-size: 13px; } + .chevron { + display: inline-flex; + transition: transform var(--duration-fast) var(--ease-out); + } + .chevron.open { + transform: rotate(90deg); + } .advanced-body { display: flex; flex-direction: column; gap: 12px; - border-left: 2px solid var(--border); + border-left: 2px solid var(--border-default); padding-left: 12px; } .field.danger .field-label, .field.danger :global(select) { - color: var(--red); + color: var(--status-danger); } .actions { display: flex; @@ -442,7 +499,7 @@ font-size: 14px; } .browser { - border: 1px solid var(--border); + border: 1px solid var(--border-default); border-radius: 8px; padding: 10px; display: flex; @@ -454,9 +511,16 @@ display: flex; align-items: center; gap: 8px; - font-family: var(--mono); + font-family: var(--font-mono); font-size: 12px; - color: var(--text-dim); + color: var(--text-secondary); + } + .browser-cwd { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .browser-list { max-height: 200px; @@ -474,13 +538,18 @@ border: none; text-align: left; flex: 1; + min-width: 0; padding: 4px 6px; + display: inline-flex; + align-items: center; + gap: 6px; + } + .entry-git { + display: inline-flex; + color: var(--text-secondary); } .entry-pick { font-size: 11px; padding: 2px 8px; } - .icon { - padding: 2px 8px; - } diff --git a/web/src/components/LoginGate.svelte b/web/src/components/LoginGate.svelte index 70977fe..cf40933 100644 --- a/web/src/components/LoginGate.svelte +++ b/web/src/components/LoginGate.svelte @@ -1,6 +1,6 @@
- {status.replace('_', ' ')} + {statusLabel(status)} {engineStateLabel(session.engineState)} + {#if session.worktreePath} + + {basename(session.worktreePath)} + + {/if} {session.cwd}
@@ -38,16 +66,17 @@ Stop engine {/if} {#if session.model}{session.model}{/if} {#if formatCost(session.lastCostUsd)} - {formatCost(session.lastCostUsd)} + {formatCost(session.lastCostUsd)} {/if}