diff --git a/.gitignore b/.gitignore index 89164b35d..8bc951e63 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ coverage/ # OS / IDE **/.DS_Store +**/._* .idea/ .vscode/ **/*.swp @@ -37,6 +38,9 @@ coverage/ .claude/settings.local.json localdocs/ execplan/ +docs/plans/ +docs/LOCAL_FORK_WORKFLOW.md +docs/GAIUS_THEME.md # Generated npm bundle output (local) cli/npm/main/ diff --git a/bun.lock b/bun.lock index 27575dd45..bc1f24036 100644 --- a/bun.lock +++ b/bun.lock @@ -122,6 +122,7 @@ "workbox-window": "^7.4.0", }, "devDependencies": { + "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -624,6 +625,8 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -880,6 +883,10 @@ "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.16.5", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-UlYpOd89C3ePAiy/6Zcz9NziN2vFIEMipBeS0mb6s4U7qP3Um6E4BwoKIUhIlriy8RxyZxRvXq1EtMxECh6M+g=="], + "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.16.5", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-Cdo2B/BCnDJRkkGHxMo8UVVcKXJyuS7bnr+JtJL4f6kp7o8T2T9o/85S05Z/TF8k5dAxz2Np8rpJgmhBd/1boA=="], + + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.16.5", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-jnl5zxT2AIslIy2X9jYklTUIob/Om8RlPIx2QbobkYP5+mMpPWUpJsBFx1NdN8FDMApcVQ/tqh6v2d72m16rDA=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -2096,6 +2103,10 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "pnpm": ["pnpm@10.27.0", "", { "bin": { "pnpm": "bin/pnpm.cjs", "pnpx": "bin/pnpx.cjs" } }, "sha512-ctaZ2haxF5wUup5k3HHJpAmIy9xlwmTLDkidt96RfyDc9NZNhyNiXylpulLUt+KhFwaC2awqXcrqq3MrfhbwSg=="], @@ -2974,6 +2985,8 @@ "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/cli/src/runner/runner.integration.test.ts b/cli/src/runner/runner.integration.test.ts index 94c3f48ae..1a4108d19 100644 --- a/cli/src/runner/runner.integration.test.ts +++ b/cli/src/runner/runner.integration.test.ts @@ -15,17 +15,18 @@ * - CLI_API_TOKEN=... (must match the hub) */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { execSync, spawn } from 'child_process'; -import { existsSync, unlinkSync, readFileSync, writeFileSync, readdirSync } from 'fs'; +import { existsSync, unlinkSync, readFileSync, writeFileSync, readdirSync, mkdirSync, cpSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; import path, { join } from 'path'; import { configuration } from '@/configuration'; -import { - listRunnerSessions, - stopRunnerSession, - spawnRunnerSession, - stopRunnerHttp, - notifyRunnerSessionStarted, +import { + listRunnerSessions, + stopRunnerSession, + spawnRunnerSession, + stopRunnerHttp, + notifyRunnerSessionStarted, stopRunner } from '@/runner/controlClient'; import { readRunnerState, clearRunnerState } from '@/persistence'; @@ -34,6 +35,55 @@ import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; import { getLatestRunnerLog } from '@/ui/logger'; import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } from '@/utils/process'; +// --- Test isolation: use a temp HAPI_HOME so tests never touch the real runner --- + +const testHapiHome = join(tmpdir(), `hapi-runner-test-${process.pid}-${Date.now()}`); +const testEnv = { ...process.env, HAPI_HOME: testHapiHome }; + +// Override the singleton configuration paths before any test runs. +// The spawned child processes get isolation via the HAPI_HOME env var. +function installTestHapiHome(): { originals: Record } { + mkdirSync(testHapiHome, { recursive: true }); + mkdirSync(join(testHapiHome, 'logs'), { recursive: true }); + + // Copy settings.json so the runner can auth with the hub + const realSettings = join(configuration.happyHomeDir, 'settings.json'); + if (existsSync(realSettings)) { + cpSync(realSettings, join(testHapiHome, 'settings.json')); + } + + const props = [ + 'happyHomeDir', + 'logsDir', + 'settingsFile', + 'privateKeyFile', + 'runnerStateFile', + 'runnerLockFile', + ] as const; + + const originals: Record = {}; + for (const prop of props) { + originals[prop] = (configuration as any)[prop]; + } + + // Redirect configuration paths to the temp dir + Object.defineProperty(configuration, 'happyHomeDir', { value: testHapiHome, configurable: true }); + Object.defineProperty(configuration, 'logsDir', { value: join(testHapiHome, 'logs'), configurable: true }); + Object.defineProperty(configuration, 'settingsFile', { value: join(testHapiHome, 'settings.json'), configurable: true }); + Object.defineProperty(configuration, 'privateKeyFile', { value: join(testHapiHome, 'access.key'), configurable: true }); + Object.defineProperty(configuration, 'runnerStateFile', { value: join(testHapiHome, 'runner.state.json'), configurable: true }); + Object.defineProperty(configuration, 'runnerLockFile', { value: join(testHapiHome, 'runner.state.json.lock'), configurable: true }); + + return { originals }; +} + +function uninstallTestHapiHome(originals: Record): void { + for (const [prop, value] of Object.entries(originals)) { + Object.defineProperty(configuration, prop, { value, configurable: true }); + } + try { rmSync(testHapiHome, { recursive: true, force: true }); } catch { /* best effort */ } +} + // Utility to wait for condition async function waitFor( condition: () => Promise, @@ -80,23 +130,38 @@ async function isServerHealthy(): Promise { describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: 20_000 }, () => { let runnerPid: number; + let savedOriginals: Record; + + beforeAll(() => { + const { originals } = installTestHapiHome(); + savedOriginals = originals; + console.log(`[TEST] Using isolated HAPI_HOME: ${testHapiHome}`); + }); + + afterAll(async () => { + try { + await stopRunner(); + } finally { + uninstallTestHapiHome(savedOriginals); + } + }); beforeEach(async () => { - // First ensure no runner is running by checking PID in metadata file + // First ensure no test runner is running by checking PID in metadata file await stopRunner() - - // Start fresh runner for this test - // This will return and start a background process - we don't need to wait for it + + // Start fresh runner for this test, using isolated HAPI_HOME void spawnHappyCLI(['runner', 'start'], { - stdio: 'ignore' + stdio: 'ignore', + env: testEnv }); - + // Wait for runner to write its state file (it needs to auth, setup, and start server) await waitFor(async () => { const state = await readRunnerState(); return state !== null; }, 10_000, 250); // Wait up to 10 seconds, checking every 250ms - + const runnerState = await readRunnerState(); if (!runnerState) { throw new Error('Runner failed to start within timeout'); @@ -200,7 +265,8 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: ], { cwd: '/tmp', detached: true, - stdio: 'ignore' + stdio: 'ignore', + env: testEnv }); if (!terminalHappyProcess || !terminalHappyProcess.pid) { throw new Error('Failed to spawn terminal hapi process'); @@ -259,7 +325,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: // Try to start another runner const secondChild = spawn('bun', ['src/index.ts', 'runner', 'start-sync'], { cwd: process.cwd(), - env: process.env, + env: testEnv, stdio: ['ignore', 'pipe', 'pipe'] }); diff --git a/docs/GAIUS_THEME.md b/docs/GAIUS_THEME.md new file mode 100644 index 000000000..f11c84762 --- /dev/null +++ b/docs/GAIUS_THEME.md @@ -0,0 +1,365 @@ +# Gaius Theme + +A warm, earthy color theme inspired by historical pigments. Ships with light and dark variants. + +## Design Philosophy + +Warm, sophisticated palette built around five named historical pigments. Light variant uses a warm pearl base; dark variant uses a deep slate base. Both maintain visual hierarchy with carefully calibrated opacity overlays for subtle UI layering. + +## Color Palette + +| Pigment | Light | Dark | Usage | +|---|---|---|---| +| **Cinnabar** | `#b04440` | `#d06058` | Links, errors, destructive actions | +| **Verdigris** | `#3a7868` | `#68b8a0` | Success, positive states | +| **Lapis** | `#4068a0` | `#6890c8` | Info, neutral highlights | +| **Gold** | `#b07830` | `#d0a060` | Warnings | +| **Violet** | `#6a5090` | `#a088c0` | Accent, secondary highlights | + +--- + +## Gaius Light + +Base: `#f8f5f2` (warm pearl) / Text: `#2a2832` (dark slate) + +### Core + +| Token | Value | Description | +|---|---|---| +| `--app-bg` | `#f8f5f2` | Background | +| `--app-fg` | `#2a2832` | Text | +| `--app-hint` | `#85808a` | Muted/secondary text | +| `--app-link` | `#b04440` | Links (cinnabar) | +| `--app-button` | `#2a2832` | Button background | +| `--app-button-text` | `#f8f5f2` | Button text | +| `--app-banner-bg` | `#eceae6` | Banner background | +| `--app-banner-text` | `#2a2832` | Banner text | +| `--app-secondary-bg` | `#f0ede9` | Secondary background | + +### Surfaces & Borders + +| Token | Value | +|---|---| +| `--app-border` | `rgba(42, 40, 50, 0.10)` | +| `--app-divider` | `rgba(42, 40, 50, 0.07)` | +| `--app-subtle-bg` | `rgba(42, 40, 50, 0.03)` | +| `--app-code-bg` | `#f0ede8` | +| `--app-inline-code-bg` | `rgba(42, 40, 50, 0.05)` | +| `--app-selected-bg` | `rgba(176, 68, 64, 0.06)` | + +### Diff + +| Token | Value | +|---|---| +| `--app-diff-added-bg` | `#e4edd8` | +| `--app-diff-added-text` | `#2a2832` | +| `--app-diff-removed-bg` | `#f2dcd8` | +| `--app-diff-removed-text` | `#2a2832` | + +### Status Colors + +| Token | Value | Pigment | +|---|---|---| +| `--app-git-staged-color` | `#3a7868` | Verdigris | +| `--app-git-unstaged-color` | `#b07830` | Gold | +| `--app-git-deleted-color` | `#b84440` | Cinnabar | +| `--app-git-renamed-color` | `#4068a0` | Lapis | +| `--app-git-untracked-color` | `#85808a` | Muted | + +### Badges + +| Type | Background | Text | Border | +|---|---|---|---| +| Info | `rgba(64, 104, 160, 0.10)` | `#3a5888` | `rgba(64, 104, 160, 0.20)` | +| Warning | `rgba(176, 120, 48, 0.12)` | `#8a5820` | `rgba(176, 120, 48, 0.22)` | +| Success | `rgba(58, 120, 104, 0.10)` | `#2a6858` | `rgba(58, 120, 104, 0.20)` | +| Error | `rgba(184, 68, 64, 0.10)` | `#983838` | `rgba(184, 68, 64, 0.20)` | + +### Brand/Flavor Accents + +| Name | Color | Background | Border | +|---|---|---|---| +| Claude | `#a04038` | `rgba(160, 64, 56, 0.08)` | `rgba(160, 64, 56, 0.18)` | +| Codex | `#2a6858` | `rgba(42, 104, 88, 0.08)` | `rgba(42, 104, 88, 0.18)` | +| Gemini | `#3a5888` | `rgba(58, 88, 136, 0.08)` | `rgba(58, 88, 136, 0.18)` | +| OpenCode | `#6a5090` | `rgba(106, 80, 144, 0.08)` | `rgba(106, 80, 144, 0.18)` | + +### Misc + +| Token | Value | +|---|---| +| `--app-perm-warning` | `#b84430` | + +--- + +## Gaius Dark + +Base: `#1e1d22` (deep slate) / Text: `#e6e3de` (light cream) + +### Core + +| Token | Value | Description | +|---|---|---| +| `--app-bg` | `#1e1d22` | Background | +| `--app-fg` | `#e6e3de` | Text | +| `--app-hint` | `#88848e` | Muted/secondary text | +| `--app-link` | `#d06058` | Links (coral) | +| `--app-button` | `#e6e3de` | Button background | +| `--app-button-text` | `#1e1d22` | Button text | +| `--app-banner-bg` | `#2a2930` | Banner background | +| `--app-banner-text` | `#e6e3de` | Banner text | +| `--app-secondary-bg` | `#252428` | Secondary background | + +### Surfaces & Borders + +| Token | Value | +|---|---| +| `--app-border` | `rgba(230, 227, 222, 0.08)` | +| `--app-divider` | `rgba(230, 227, 222, 0.06)` | +| `--app-subtle-bg` | `rgba(230, 227, 222, 0.04)` | +| `--app-code-bg` | `#252430` | +| `--app-inline-code-bg` | `rgba(230, 227, 222, 0.07)` | +| `--app-selected-bg` | `rgba(208, 96, 88, 0.06)` | + +### Diff + +| Token | Value | +|---|---| +| `--app-diff-added-bg` | `rgba(80, 150, 130, 0.12)` | +| `--app-diff-added-text` | `#d8d5d0` | +| `--app-diff-removed-bg` | `rgba(200, 80, 70, 0.12)` | +| `--app-diff-removed-text` | `#d8d5d0` | + +### Status Colors + +| Token | Value | +|---|---| +| `--app-git-staged-color` | `#68b8a0` | +| `--app-git-unstaged-color` | `#d0a060` | +| `--app-git-deleted-color` | `#d87068` | +| `--app-git-renamed-color` | `#6890c8` | +| `--app-git-untracked-color` | `#88848e` | + +### Badges + +| Type | Background | Text | Border | +|---|---|---|---| +| Info | `rgba(104, 144, 200, 0.12)` | `#6890c8` | `rgba(104, 144, 200, 0.20)` | +| Warning | `rgba(208, 160, 96, 0.15)` | `#d0a060` | `rgba(208, 160, 96, 0.25)` | +| Success | `rgba(104, 184, 160, 0.10)` | `#68b8a0` | `rgba(104, 184, 160, 0.20)` | +| Error | `rgba(216, 112, 104, 0.12)` | `#d87068` | `rgba(216, 112, 104, 0.22)` | + +### Brand/Flavor Accents + +| Name | Color | Background | Border | +|---|---|---|---| +| Claude | `#d08858` | `rgba(208, 136, 88, 0.10)` | `rgba(208, 136, 88, 0.20)` | +| Codex | `#68b8a0` | `rgba(104, 184, 160, 0.10)` | `rgba(104, 184, 160, 0.20)` | +| Gemini | `#6890c8` | `rgba(104, 144, 200, 0.10)` | `rgba(104, 144, 200, 0.20)` | +| OpenCode | `#a088c0` | `rgba(160, 136, 192, 0.10)` | `rgba(160, 136, 192, 0.20)` | + +### Misc + +| Token | Value | +|---|---| +| `--app-perm-warning` | `#d08050` | + +--- + +## Raw CSS + +Copy-paste ready for use in other projects. Apply via `data-theme` attribute on a root element. + +### Gaius Light + +```css +[data-theme="gaius-light"] { + /* Primary — warm pearl base */ + --app-bg: #f8f5f2; + --app-fg: #2a2832; + --app-hint: #85808a; + --app-link: #b04440; + --app-button: #2a2832; + --app-button-text: #f8f5f2; + --app-banner-bg: #eceae6; + --app-banner-text: #2a2832; + --app-secondary-bg: #f0ede9; + --app-selected-bg: rgba(176, 68, 64, 0.06); + + /* Overlays */ + --app-border: rgba(42, 40, 50, 0.10); + --app-divider: rgba(42, 40, 50, 0.07); + --app-subtle-bg: rgba(42, 40, 50, 0.03); + --app-code-bg: #f0ede8; + --app-inline-code-bg: rgba(42, 40, 50, 0.05); + + /* Diffs — verdigris added, cinnabar removed */ + --app-diff-added-bg: #e4edd8; + --app-diff-added-text: #2a2832; + --app-diff-removed-bg: #f2dcd8; + --app-diff-removed-text: #2a2832; + + /* Git status */ + --app-git-staged-color: #3a7868; + --app-git-unstaged-color: #b07830; + --app-git-deleted-color: #b84440; + --app-git-renamed-color: #4068a0; + --app-git-untracked-color: #85808a; + + /* Badges — lapis, gold, verdigris, cinnabar */ + --app-badge-info-bg: rgba(64, 104, 160, 0.10); + --app-badge-info-text: #3a5888; + --app-badge-info-border: rgba(64, 104, 160, 0.20); + --app-badge-warning-bg: rgba(176, 120, 48, 0.12); + --app-badge-warning-text: #8a5820; + --app-badge-warning-border: rgba(176, 120, 48, 0.22); + --app-badge-success-bg: rgba(58, 120, 104, 0.10); + --app-badge-success-text: #2a6858; + --app-badge-success-border: rgba(58, 120, 104, 0.20); + --app-badge-error-bg: rgba(184, 68, 64, 0.10); + --app-badge-error-text: #983838; + --app-badge-error-border: rgba(184, 68, 64, 0.20); + + --app-perm-warning: #b84430; + + /* Agent flavors — cinnabar, verdigris, lapis, violet */ + --app-flavor-claude: #a04038; + --app-flavor-claude-bg: rgba(160, 64, 56, 0.08); + --app-flavor-claude-border: rgba(160, 64, 56, 0.18); + --app-flavor-codex: #2a6858; + --app-flavor-codex-bg: rgba(42, 104, 88, 0.08); + --app-flavor-codex-border: rgba(42, 104, 88, 0.18); + --app-flavor-gemini: #3a5888; + --app-flavor-gemini-bg: rgba(58, 88, 136, 0.08); + --app-flavor-gemini-border: rgba(58, 88, 136, 0.18); + --app-flavor-opencode: #6a5090; + --app-flavor-opencode-bg: rgba(106, 80, 144, 0.08); + --app-flavor-opencode-border: rgba(106, 80, 144, 0.18); +} +``` + +### Gaius Dark + +```css +[data-theme="gaius-dark"] { + /* Primary — deep slate base */ + --app-bg: #1e1d22; + --app-fg: #e6e3de; + --app-hint: #88848e; + --app-link: #d06058; + --app-button: #e6e3de; + --app-button-text: #1e1d22; + --app-banner-bg: #2a2930; + --app-banner-text: #e6e3de; + --app-secondary-bg: #252428; + --app-selected-bg: rgba(208, 96, 88, 0.06); + + /* Overlays */ + --app-border: rgba(230, 227, 222, 0.08); + --app-divider: rgba(230, 227, 222, 0.06); + --app-subtle-bg: rgba(230, 227, 222, 0.04); + --app-code-bg: #252430; + --app-inline-code-bg: rgba(230, 227, 222, 0.07); + + /* Diffs */ + --app-diff-added-bg: rgba(80, 150, 130, 0.12); + --app-diff-added-text: #d8d5d0; + --app-diff-removed-bg: rgba(200, 80, 70, 0.12); + --app-diff-removed-text: #d8d5d0; + + /* Git status */ + --app-git-staged-color: #68b8a0; + --app-git-unstaged-color: #d0a060; + --app-git-deleted-color: #d87068; + --app-git-renamed-color: #6890c8; + --app-git-untracked-color: #88848e; + + /* Badges */ + --app-badge-info-bg: rgba(104, 144, 200, 0.12); + --app-badge-info-text: #6890c8; + --app-badge-info-border: rgba(104, 144, 200, 0.20); + --app-badge-warning-bg: rgba(208, 160, 96, 0.15); + --app-badge-warning-text: #d0a060; + --app-badge-warning-border: rgba(208, 160, 96, 0.25); + --app-badge-success-bg: rgba(104, 184, 160, 0.10); + --app-badge-success-text: #68b8a0; + --app-badge-success-border: rgba(104, 184, 160, 0.20); + --app-badge-error-bg: rgba(216, 112, 104, 0.12); + --app-badge-error-text: #d87068; + --app-badge-error-border: rgba(216, 112, 104, 0.22); + + --app-perm-warning: #d08050; + + /* Agent flavors */ + --app-flavor-claude: #d08858; + --app-flavor-claude-bg: rgba(208, 136, 88, 0.10); + --app-flavor-claude-border: rgba(208, 136, 88, 0.20); + --app-flavor-codex: #68b8a0; + --app-flavor-codex-bg: rgba(104, 184, 160, 0.10); + --app-flavor-codex-border: rgba(104, 184, 160, 0.20); + --app-flavor-gemini: #6890c8; + --app-flavor-gemini-bg: rgba(104, 144, 200, 0.10); + --app-flavor-gemini-border: rgba(104, 144, 200, 0.20); + --app-flavor-opencode: #a088c0; + --app-flavor-opencode-bg: rgba(160, 136, 192, 0.10); + --app-flavor-opencode-border: rgba(160, 136, 192, 0.20); +} +``` + +--- + +## Token Reference + +Full list of CSS custom properties used: + +``` +--app-bg +--app-fg +--app-hint +--app-link +--app-button +--app-button-text +--app-banner-bg +--app-banner-text +--app-secondary-bg +--app-selected-bg +--app-border +--app-divider +--app-subtle-bg +--app-code-bg +--app-inline-code-bg +--app-diff-added-bg +--app-diff-added-text +--app-diff-removed-bg +--app-diff-removed-text +--app-git-staged-color +--app-git-unstaged-color +--app-git-deleted-color +--app-git-renamed-color +--app-git-untracked-color +--app-badge-info-bg +--app-badge-info-text +--app-badge-info-border +--app-badge-warning-bg +--app-badge-warning-text +--app-badge-warning-border +--app-badge-success-bg +--app-badge-success-text +--app-badge-success-border +--app-badge-error-bg +--app-badge-error-text +--app-badge-error-border +--app-flavor-claude +--app-flavor-claude-bg +--app-flavor-claude-border +--app-flavor-codex +--app-flavor-codex-bg +--app-flavor-codex-border +--app-flavor-gemini +--app-flavor-gemini-bg +--app-flavor-gemini-border +--app-flavor-opencode +--app-flavor-opencode-bg +--app-flavor-opencode-border +--app-perm-warning +``` diff --git a/docs/LOCAL_FORK_WORKFLOW.md b/docs/LOCAL_FORK_WORKFLOW.md new file mode 100755 index 000000000..4b2057c5f --- /dev/null +++ b/docs/LOCAL_FORK_WORKFLOW.md @@ -0,0 +1,114 @@ +# Local Fork Workflow (copy/paste friendly) + +Use this when you want to run **your forked HAPI build** locally and keep it updated with `tiann/hapi`. + +Current remote layout in this repo: + +- `origin` = upstream (`https://github.com/tiann/hapi.git`) +- `fork` = your fork (`https://github.com/gaius-codius/hapi.git`) + +--- + +## 1) One-time switch from global npm package to your local fork build + +```bash +# from anywhere +which hapi +npm list -g @twsxtd/hapi +npm uninstall -g @twsxtd/hapi +hash -r + +# from repo root +cd /home/gretus/code/hapi +git checkout main +bun install +bun run build:single-exe + +# install your built binary to user path +install -Dm755 cli/dist-exe/bun-linux-x64/hapi ~/.local/bin/hapi +hash -r + +# verify +which hapi +hapi --version +``` + +If `~/.local/bin` not in PATH, add it: + +```bash +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +--- + +## 2) Manual ongoing update flow + +```bash +cd /home/gretus/code/hapi +git checkout main +git fetch origin +git fetch fork +git merge origin/main +bun install +bun run build:single-exe +install -Dm755 cli/dist-exe/bun-linux-x64/hapi ~/.local/bin/hapi +git push fork main +``` + +Optional extra validation before build: + +```bash +bun run typecheck +bun run test:web +``` + +--- + +## 3) Scripted ongoing update flow (recommended) + +Script added at: + +```text +scripts/update-local-hapi.sh +``` + +Make executable once: + +```bash +cd /home/gretus/code/hapi +chmod +x scripts/update-local-hapi.sh +``` + +Run it: + +```bash +cd /home/gretus/code/hapi +./scripts/update-local-hapi.sh +``` + +Run with validation: + +```bash +cd /home/gretus/code/hapi +RUN_TESTS=1 ./scripts/update-local-hapi.sh +``` + +Optional env overrides: + +```bash +UPSTREAM_REMOTE=origin FORK_REMOTE=fork MAIN_BRANCH=main INSTALL_DIR="$HOME/.local/bin" ./scripts/update-local-hapi.sh +``` + +--- + +## 4) Quick checks + +```bash +git remote -v +git status -sb +which hapi +hapi --version +``` + +Going forward, run ./scripts/update-local-hapi.sh from the repo root to pull upstream changes, rebuild, and reinstall. \ No newline at end of file diff --git a/hub/README.md b/hub/README.md index faed72973..8bef705f9 100644 --- a/hub/README.md +++ b/hub/README.md @@ -151,7 +151,7 @@ Namespace: `/cli` - `message` - Send message to session. - `update-metadata` - Update session metadata. - `update-state` - Update agent state. -- `session-alive` - Keep session active. +- `session-alive` - Keep session active. Payloads are validated against the shared protocol schemas; unknown `permissionMode`, `modelMode`, or `collaborationMode` values and out-of-range `sid`/`time` values cause the heartbeat to be dropped. Forked or older CLIs emitting non-standard values must upgrade. - `session-end` - Mark session ended. - `machine-alive` - Keep machine online. - `rpc-register` - Register RPC handler. diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 67ec014b7..845ef7a6c 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -1,7 +1,8 @@ import type { ClientToServerEvents } from '@hapi/protocol' +import { CodexCollaborationModeSchema, ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { z } from 'zod' import { randomUUID } from 'node:crypto' -import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, ModelMode, PermissionMode } from '@hapi/protocol/types' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' @@ -18,6 +19,7 @@ type SessionAlivePayload = { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: ModelMode } type SessionEndPayload = { @@ -50,6 +52,18 @@ const updateStateSchema = z.object({ agentState: z.unknown().nullable() }) +const sessionAliveSchema = z.object({ + sid: z.string().min(1).max(128), + time: z.number().finite().nonnegative(), + thinking: z.boolean().optional(), + mode: z.enum(['local', 'remote']).optional(), + permissionMode: PermissionModeSchema.optional(), + model: z.string().max(256).nullable().optional(), + effort: z.string().max(64).nullable().optional(), + collaborationMode: CodexCollaborationModeSchema.optional(), + modelMode: ModelModeSchema.optional() +}) + export type SessionHandlersDeps = { store: Store resolveSessionAccess: ResolveSessionAccess @@ -235,15 +249,17 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session socket.on('update-state', handleUpdateState) socket.on('session-alive', (data: SessionAlivePayload) => { - if (!data || typeof data.sid !== 'string' || typeof data.time !== 'number') { + const parsed = sessionAliveSchema.safeParse(data) + if (!parsed.success) { return } - const sessionAccess = resolveSessionAccess(data.sid) + const payload = parsed.data + const sessionAccess = resolveSessionAccess(payload.sid) if (!sessionAccess.ok) { - emitAccessError('session', data.sid, sessionAccess.reason) + emitAccessError('session', payload.sid, sessionAccess.reason) return } - onSessionAlive?.(data) + onSessionAlive?.(payload) }) socket.on('session-end', (data: SessionEndPayload) => { diff --git a/hub/src/sse/sseManager.test.ts b/hub/src/sse/sseManager.test.ts index 209c278f8..2ccb85b8e 100644 --- a/hub/src/sse/sseManager.test.ts +++ b/hub/src/sse/sseManager.test.ts @@ -118,4 +118,73 @@ describe('SSEManager namespace filtering', () => { expect(received).toHaveLength(1) expect(received[0]?.id).toBe('visible') }) + + it('broadcasts session sort preference updates to namespace even with session-scoped subscription', () => { + const manager = new SSEManager(0, new VisibilityTracker()) + const received: SyncEvent[] = [] + + manager.subscribe({ + id: 'alpha-session', + namespace: 'alpha', + userId: 1, + all: false, + sessionId: 'session-1', + send: (event) => { + received.push(event) + }, + sendHeartbeat: () => {} + }) + + manager.broadcast({ + type: 'session-sort-preference-updated', + namespace: 'alpha', + data: { + userId: 1, + version: 2 + } + }) + + expect(received).toHaveLength(1) + expect(received[0]?.type).toBe('session-sort-preference-updated') + }) + + it('filters session sort preference updates by userId', () => { + const manager = new SSEManager(0, new VisibilityTracker()) + const receivedUser1: SyncEvent[] = [] + const receivedUser2: SyncEvent[] = [] + + manager.subscribe({ + id: 'user1-conn', + namespace: 'alpha', + userId: 1, + all: true, + send: (event) => { + receivedUser1.push(event) + }, + sendHeartbeat: () => {} + }) + + manager.subscribe({ + id: 'user2-conn', + namespace: 'alpha', + userId: 2, + all: true, + send: (event) => { + receivedUser2.push(event) + }, + sendHeartbeat: () => {} + }) + + manager.broadcast({ + type: 'session-sort-preference-updated', + namespace: 'alpha', + data: { + userId: 1, + version: 3 + } + }) + + expect(receivedUser1).toHaveLength(1) + expect(receivedUser2).toHaveLength(0) + }) }) diff --git a/hub/src/sse/sseManager.ts b/hub/src/sse/sseManager.ts index 2bd267b9d..87069452f 100644 --- a/hub/src/sse/sseManager.ts +++ b/hub/src/sse/sseManager.ts @@ -5,6 +5,7 @@ import type { VisibilityTracker } from '../visibility/visibilityTracker' export type SSESubscription = { id: string namespace: string + userId: number | null all: boolean sessionId: string | null machineId: string | null @@ -29,6 +30,7 @@ export class SSEManager { subscribe(options: { id: string namespace: string + userId?: number | null all?: boolean sessionId?: string | null machineId?: string | null @@ -39,6 +41,7 @@ export class SSEManager { const subscription: SSEConnection = { id: options.id, namespace: options.namespace, + userId: options.userId ?? null, all: Boolean(options.all), sessionId: options.sessionId ?? null, machineId: options.machineId ?? null, @@ -56,6 +59,7 @@ export class SSEManager { return { id: subscription.id, namespace: subscription.namespace, + userId: subscription.userId, all: subscription.all, sessionId: subscription.sessionId, machineId: subscription.machineId @@ -163,6 +167,10 @@ export class SSEManager { return true } + if (event.type === 'session-sort-preference-updated') { + return connection.userId === null || connection.userId === event.data.userId + } + if (connection.all) { return true } diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index f70b3db25..58515a373 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path' import { MachineStore } from './machineStore' import { MessageStore } from './messageStore' import { PushStore } from './pushStore' +import { SessionSortPreferenceStore } from './sessionSortPreferenceStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' @@ -13,22 +14,26 @@ export type { StoredMessage, StoredPushSubscription, StoredSession, + StoredSessionSortPreference, StoredUser, + SessionSortPreferenceUpdateResult, VersionedUpdateResult } from './types' export { MachineStore } from './machineStore' export { MessageStore } from './messageStore' export { PushStore } from './pushStore' +export { SessionSortPreferenceStore } from './sessionSortPreferenceStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 6 +const SCHEMA_VERSION: number = 7 const REQUIRED_TABLES = [ 'sessions', 'machines', 'messages', 'users', - 'push_subscriptions' + 'push_subscriptions', + 'session_sort_preferences' ] as const export class Store { @@ -40,6 +45,7 @@ export class Store { readonly messages: MessageStore readonly users: UserStore readonly push: PushStore + readonly sessionSortPreferences: SessionSortPreferenceStore constructor(dbPath: string) { this.dbPath = dbPath @@ -81,6 +87,7 @@ export class Store { this.messages = new MessageStore(this.db) this.users = new UserStore(this.db) this.push = new PushStore(this.db) + this.sessionSortPreferences = new SessionSortPreferenceStore(this.db) } private initSchema(): void { @@ -88,55 +95,36 @@ export class Store { if (currentVersion === 0) { if (this.hasAnyUserTables()) { this.migrateLegacySchemaIfNeeded() - this.createSchema() - this.setUserVersion(SCHEMA_VERSION) - return } - this.createSchema() this.setUserVersion(SCHEMA_VERSION) return } - if (currentVersion === 1 && SCHEMA_VERSION === 2) { - this.migrateFromV1ToV2() - this.setUserVersion(SCHEMA_VERSION) - return - } - - if (currentVersion === 2 && SCHEMA_VERSION === 3) { - this.migrateFromV2ToV3() - this.setUserVersion(SCHEMA_VERSION) - return - } - - if (currentVersion === 3 && SCHEMA_VERSION === 4) { - this.migrateFromV3ToV4() - this.setUserVersion(SCHEMA_VERSION) - return - } - - if (currentVersion === 4 && SCHEMA_VERSION === 5) { - this.migrateFromV4ToV5() - this.setUserVersion(SCHEMA_VERSION) - return - } - - if (currentVersion === 5 && SCHEMA_VERSION === 6) { - this.migrateFromV5ToV6() - this.setUserVersion(SCHEMA_VERSION) - return + if (currentVersion > SCHEMA_VERSION) { + throw this.buildSchemaMismatchError(currentVersion) } - if (currentVersion === 4 && SCHEMA_VERSION === 6) { - this.migrateFromV4ToV5() - this.migrateFromV5ToV6() - this.setUserVersion(SCHEMA_VERSION) - return - } + let version = currentVersion + while (version < SCHEMA_VERSION) { + if (version === 1) { + this.migrateFromV1ToV2() + } else if (version === 2) { + this.migrateFromV2ToV3() + } else if (version === 3) { + this.migrateFromV3ToV4() + } else if (version === 4) { + this.migrateFromV4ToV5() + } else if (version === 5) { + this.migrateFromV5ToV6() + } else if (version === 6) { + this.migrateFromV6ToV7() + } else { + throw this.buildSchemaMismatchError(version) + } - if (currentVersion !== SCHEMA_VERSION) { - throw this.buildSchemaMismatchError(currentVersion) + version += 1 + this.setUserVersion(version) } this.assertRequiredTablesPresent() @@ -217,6 +205,25 @@ export class Store { ); CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); `) + + this.createSessionSortPreferencesTable() + } + + private createSessionSortPreferencesTable(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_sort_preferences ( + user_id INTEGER NOT NULL, + namespace TEXT NOT NULL, + sort_mode TEXT NOT NULL DEFAULT 'auto', + manual_order TEXT NOT NULL DEFAULT '{"groupOrder":[],"sessionOrder":{}}', + version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, namespace) + ); + CREATE INDEX IF NOT EXISTS idx_session_sort_preferences_namespace + ON session_sort_preferences(namespace); + `) } private migrateLegacySchemaIfNeeded(): void { @@ -260,7 +267,7 @@ export class Store { this.db.exec('ALTER TABLE machines RENAME COLUMN daemon_state_version TO runner_state_version') this.db.exec('COMMIT') return - } catch (error) { + } catch { this.db.exec('ROLLBACK') } @@ -310,6 +317,10 @@ export class Store { } private migrateFromV3ToV4(): void { + this.createSessionSortPreferencesTable() + } + + private migrateFromV4ToV5(): void { const columns = this.getSessionColumnNames() if (!columns.has('team_state')) { this.db.exec('ALTER TABLE sessions ADD COLUMN team_state TEXT') @@ -319,18 +330,19 @@ export class Store { } } - private migrateFromV4ToV5(): void { + private migrateFromV5ToV6(): void { const columns = this.getSessionColumnNames() if (!columns.has('model')) { this.db.exec('ALTER TABLE sessions ADD COLUMN model TEXT') } } - private migrateFromV5ToV6(): void { + private migrateFromV6ToV7(): void { const columns = this.getSessionColumnNames() if (!columns.has('effort')) { this.db.exec('ALTER TABLE sessions ADD COLUMN effort TEXT') } + this.createSessionSortPreferencesTable() } private getSessionColumnNames(): Set { diff --git a/hub/src/store/sessionSortPreferenceStore.test.ts b/hub/src/store/sessionSortPreferenceStore.test.ts new file mode 100644 index 000000000..a889e0971 --- /dev/null +++ b/hub/src/store/sessionSortPreferenceStore.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'bun:test' + +import { Store } from './index' + +describe('SessionSortPreferenceStore', () => { + it('returns default preference when no row exists', () => { + const store = new Store(':memory:') + + const preference = store.sessionSortPreferences.getByUser(1, 'alpha') + + expect(preference.sortMode).toBe('auto') + expect(preference.manualOrder).toEqual({ groupOrder: [], sessionOrder: {} }) + expect(preference.version).toBe(1) + expect(preference.updatedAt).toBe(0) + }) + + it('persists preference updates for user and namespace', () => { + const store = new Store(':memory:') + + const result = store.sessionSortPreferences.upsertByUser( + 1, + 'alpha', + { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + } + }, + 1 + ) + + expect(result.result).toBe('success') + if (result.result !== 'success') { + return + } + expect(result.preference.version).toBe(2) + + const stored = store.sessionSortPreferences.getByUser(1, 'alpha') + expect(stored.sortMode).toBe('manual') + expect(stored.manualOrder.groupOrder).toEqual(['m1::/repo/app']) + expect(stored.manualOrder.sessionOrder['m1::/repo/app']).toEqual(['session-1']) + expect(stored.version).toBe(2) + expect(stored.updatedAt).toBeGreaterThan(0) + }) + + it('returns version mismatch with latest preference', () => { + const store = new Store(':memory:') + + store.sessionSortPreferences.upsertByUser( + 1, + 'alpha', + { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + } + }, + 1 + ) + + const mismatch = store.sessionSortPreferences.upsertByUser( + 1, + 'alpha', + { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + } + }, + 1 + ) + + expect(mismatch.result).toBe('version-mismatch') + if (mismatch.result !== 'version-mismatch') { + return + } + + expect(mismatch.preference.sortMode).toBe('manual') + expect(mismatch.preference.version).toBe(2) + }) +}) diff --git a/hub/src/store/sessionSortPreferenceStore.ts b/hub/src/store/sessionSortPreferenceStore.ts new file mode 100644 index 000000000..4786c4991 --- /dev/null +++ b/hub/src/store/sessionSortPreferenceStore.ts @@ -0,0 +1,29 @@ +import type { Database } from 'bun:sqlite' +import type { SessionManualOrder, SessionSortMode } from '@hapi/protocol/types' + +import type { SessionSortPreferenceUpdateResult, StoredSessionSortPreference } from './types' +import { getSessionSortPreferenceByUser, upsertSessionSortPreferenceByUser } from './sessionSortPreferences' + +export class SessionSortPreferenceStore { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + getByUser(userId: number, namespace: string): StoredSessionSortPreference { + return getSessionSortPreferenceByUser(this.db, userId, namespace) + } + + upsertByUser( + userId: number, + namespace: string, + preference: { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + }, + expectedVersion?: number + ): SessionSortPreferenceUpdateResult { + return upsertSessionSortPreferenceByUser(this.db, userId, namespace, preference, expectedVersion) + } +} diff --git a/hub/src/store/sessionSortPreferences.ts b/hub/src/store/sessionSortPreferences.ts new file mode 100644 index 000000000..575c34b7d --- /dev/null +++ b/hub/src/store/sessionSortPreferences.ts @@ -0,0 +1,146 @@ +import type { Database } from 'bun:sqlite' +import { SessionManualOrderSchema, type SessionManualOrder, type SessionSortMode } from '@hapi/protocol/schemas' + +import type { SessionSortPreferenceUpdateResult, StoredSessionSortPreference } from './types' +import { safeJsonParse } from './json' + +const EMPTY_MANUAL_ORDER: SessionManualOrder = { + groupOrder: [], + sessionOrder: {} +} + +type DbSessionSortPreferenceRow = { + user_id: number + namespace: string + sort_mode: string + manual_order: string + version: number + created_at: number + updated_at: number +} + +function normalizeSortMode(value: string): SessionSortMode { + return value === 'manual' ? 'manual' : 'auto' +} + +function normalizeManualOrder(value: unknown): SessionManualOrder { + const parsed = SessionManualOrderSchema.safeParse(value) + if (parsed.success) { + return parsed.data + } + + return { + groupOrder: [], + sessionOrder: {} + } +} + +function toStoredSessionSortPreference(row: DbSessionSortPreferenceRow): StoredSessionSortPreference { + return { + userId: row.user_id, + namespace: row.namespace, + sortMode: normalizeSortMode(row.sort_mode), + manualOrder: normalizeManualOrder(safeJsonParse(row.manual_order)), + version: Math.max(1, row.version), + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +function getDefaultSessionSortPreference(userId: number, namespace: string): StoredSessionSortPreference { + return { + userId, + namespace, + sortMode: 'auto', + manualOrder: EMPTY_MANUAL_ORDER, + version: 1, + createdAt: 0, + updatedAt: 0 + } +} + +export function getSessionSortPreferenceByUser( + db: Database, + userId: number, + namespace: string +): StoredSessionSortPreference { + const row = db.prepare( + 'SELECT * FROM session_sort_preferences WHERE user_id = ? AND namespace = ? LIMIT 1' + ).get(userId, namespace) as DbSessionSortPreferenceRow | undefined + + if (!row) { + return getDefaultSessionSortPreference(userId, namespace) + } + + return toStoredSessionSortPreference(row) +} + +export function upsertSessionSortPreferenceByUser( + db: Database, + userId: number, + namespace: string, + preference: { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + }, + expectedVersion?: number +): SessionSortPreferenceUpdateResult { + try { + const current = getSessionSortPreferenceByUser(db, userId, namespace) + if (expectedVersion !== undefined && expectedVersion !== current.version) { + return { + result: 'version-mismatch', + preference: current + } + } + + const now = Date.now() + const nextVersion = current.version + 1 + const manualOrderJson = JSON.stringify(preference.manualOrder) + + db.prepare(` + INSERT INTO session_sort_preferences ( + user_id, + namespace, + sort_mode, + manual_order, + version, + created_at, + updated_at + ) VALUES ( + @user_id, + @namespace, + @sort_mode, + @manual_order, + @version, + @created_at, + @updated_at + ) + ON CONFLICT(user_id, namespace) + DO UPDATE SET + sort_mode = excluded.sort_mode, + manual_order = excluded.manual_order, + version = excluded.version, + updated_at = excluded.updated_at + `).run({ + user_id: userId, + namespace, + sort_mode: preference.sortMode, + manual_order: manualOrderJson, + version: nextVersion, + created_at: current.createdAt || now, + updated_at: now + }) + + const updated = getSessionSortPreferenceByUser(db, userId, namespace) + return { + result: 'success', + preference: updated + } + } catch (error) { + console.error('Failed to upsert session sort preference:', error) + return { + result: 'error' + } + } +} diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 394c86f32..6908a7e6c 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -1,3 +1,5 @@ +import type { SessionManualOrder, SessionSortMode } from '@hapi/protocol/types' + export type StoredSession = { id: string tag: string | null @@ -60,6 +62,21 @@ export type StoredPushSubscription = { createdAt: number } +export type StoredSessionSortPreference = { + userId: number + namespace: string + sortMode: SessionSortMode + manualOrder: SessionManualOrder + version: number + createdAt: number + updatedAt: number +} + +export type SessionSortPreferenceUpdateResult = + | { result: 'success'; preference: StoredSessionSortPreference } + | { result: 'version-mismatch'; preference: StoredSessionSortPreference } + | { result: 'error' } + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 618447c7d..c1a45d158 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -136,7 +136,8 @@ export class SessionCache { model: stored.model, effort: stored.effort, permissionMode: existing?.permissionMode, - collaborationMode: existing?.collaborationMode + collaborationMode: existing?.collaborationMode, + modelMode: existing?.modelMode } this.sessions.set(sessionId, session) @@ -160,6 +161,7 @@ export class SessionCache { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: Session['modelMode'] }): void { const t = clampAliveTime(payload.time) if (!t) return @@ -173,6 +175,7 @@ export class SessionCache { const previousModel = session.model const previousEffort = session.effort const previousCollaborationMode = session.collaborationMode + const previousModelMode = session.modelMode session.active = true session.activeAt = Math.max(session.activeAt, t) @@ -200,6 +203,9 @@ export class SessionCache { if (payload.collaborationMode !== undefined) { session.collaborationMode = payload.collaborationMode } + if (payload.modelMode !== undefined) { + session.modelMode = payload.modelMode + } const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0 @@ -207,6 +213,7 @@ export class SessionCache { || previousModel !== session.model || previousEffort !== session.effort || previousCollaborationMode !== session.collaborationMode + || previousModelMode !== session.modelMode const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) || modeChanged @@ -224,7 +231,8 @@ export class SessionCache { permissionMode: session.permissionMode, model: session.model, effort: session.effort, - collaborationMode: session.collaborationMode + collaborationMode: session.collaborationMode, + modelMode: session.modelMode } }) } @@ -266,6 +274,7 @@ export class SessionCache { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: Session['modelMode'] } ): void { const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) @@ -301,6 +310,9 @@ export class SessionCache { if (config.collaborationMode !== undefined) { session.collaborationMode = config.collaborationMode } + if (config.modelMode !== undefined) { + session.modelMode = config.modelMode + } this.publisher.emit({ type: 'session-updated', sessionId, data: session }) } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..77e147b54 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,16 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { + CodexCollaborationMode, + DecryptedMessage, + PermissionMode, + Session, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, + SyncEvent +} from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -42,7 +51,13 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +export type SetSessionSortPreferenceResult = + | { result: 'success'; preference: SessionSortPreference } + | { result: 'version-mismatch'; preference: SessionSortPreference } + | { result: 'error' } + export class SyncEngine { + private readonly store: Store private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache private readonly machineCache: MachineCache @@ -56,6 +71,7 @@ export class SyncEngine { rpcRegistry: RpcRegistry, sseManager: SSEManager ) { + this.store = store this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) this.machineCache = new MachineCache(store, this.eventPublisher) @@ -97,6 +113,63 @@ export class SyncEngine { return this.sessionCache.getSessionsByNamespace(namespace) } + getSessionSortPreference(userId: number, namespace: string): SessionSortPreference { + const preference = this.store.sessionSortPreferences.getByUser(userId, namespace) + return { + sortMode: preference.sortMode, + manualOrder: preference.manualOrder, + version: preference.version, + updatedAt: preference.updatedAt + } + } + + setSessionSortPreference( + userId: number, + namespace: string, + input: { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + expectedVersion?: number + } + ): SetSessionSortPreferenceResult { + const result = this.store.sessionSortPreferences.upsertByUser( + userId, + namespace, + { + sortMode: input.sortMode, + manualOrder: input.manualOrder + }, + input.expectedVersion + ) + + if (result.result === 'error') { + return { result: 'error' } + } + + const preference: SessionSortPreference = { + sortMode: result.preference.sortMode, + manualOrder: result.preference.manualOrder, + version: result.preference.version, + updatedAt: result.preference.updatedAt + } + + if (result.result === 'success') { + this.eventPublisher.emit({ + type: 'session-sort-preference-updated', + namespace, + data: { + userId, + version: preference.version + } + }) + } + + return { + result: result.result, + preference + } + } + getSession(sessionId: string): Session | undefined { return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined } @@ -190,6 +263,7 @@ export class SyncEngine { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: Session['modelMode'] }): void { this.sessionCache.handleSessionAlive(payload) } @@ -293,9 +367,16 @@ export class SyncEngine { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: Session['modelMode'] } ): Promise { - const result = await this.rpcGateway.requestSessionConfig(sessionId, config) + const rpcConfig = { + permissionMode: config.permissionMode, + model: config.model, + effort: config.effort, + collaborationMode: config.collaborationMode + } + const result = await this.rpcGateway.requestSessionConfig(sessionId, rpcConfig) if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') } @@ -312,7 +393,10 @@ export class SyncEngine { throw new Error('Missing applied session config') } - this.sessionCache.applySessionConfig(sessionId, applied) + this.sessionCache.applySessionConfig(sessionId, { + ...applied, + modelMode: config.modelMode + }) } async spawnSession( diff --git a/hub/src/web/routes/events.ts b/hub/src/web/routes/events.ts index 557b57db4..750ceb43a 100644 --- a/hub/src/web/routes/events.ts +++ b/hub/src/web/routes/events.ts @@ -77,10 +77,13 @@ export function createEventsRoutes( } } + const userId = c.get('userId') + return streamSSE(c, async (stream) => { manager.subscribe({ id: subscriptionId, namespace, + userId, all, sessionId: resolvedSessionId, machineId, diff --git a/hub/src/web/routes/preferences.test.ts b/hub/src/web/routes/preferences.test.ts new file mode 100644 index 000000000..b4ce14b13 --- /dev/null +++ b/hub/src/web/routes/preferences.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' + +import type { SetSessionSortPreferenceResult, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createPreferencesRoutes } from './preferences' + +function createApp(options: { + userId: number + namespace: string + engine: SyncEngine +}): Hono { + const app = new Hono() + + app.use('/api/*', async (c, next) => { + c.set('userId', options.userId) + c.set('namespace', options.namespace) + await next() + }) + + app.route('/api', createPreferencesRoutes(() => options.engine)) + return app +} + +describe('preferences routes', () => { + it('GET returns sort preference for authenticated user and namespace', async () => { + const captured: { userId?: number; namespace?: string } = {} + const engine = { + getSessionSortPreference: (userId: number, namespace: string) => { + captured.userId = userId + captured.namespace = namespace + return { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + version: 1, + updatedAt: 0 + } + } + } as unknown as SyncEngine + + const app = createApp({ userId: 7, namespace: 'alpha', engine }) + const response = await app.request('http://localhost/api/preferences/session-sort') + + expect(response.status).toBe(200) + const json = await response.json() as { + preference: { + sortMode: string + version: number + } + } + expect(json.preference.sortMode).toBe('auto') + expect(json.preference.version).toBe(1) + expect(captured).toEqual({ userId: 7, namespace: 'alpha' }) + }) + + it('PUT persists preference and returns updated snapshot', async () => { + const engine = { + setSessionSortPreference: () => ({ + result: 'success', + preference: { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + }, + version: 2, + updatedAt: 100 + } + } satisfies SetSessionSortPreferenceResult) + } as unknown as SyncEngine + + const app = createApp({ userId: 9, namespace: 'beta', engine }) + + const response = await app.request('http://localhost/api/preferences/session-sort', { + method: 'PUT', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + }, + expectedVersion: 1 + }) + }) + + expect(response.status).toBe(200) + const json = await response.json() as { + preference: { + sortMode: string + version: number + } + } + expect(json.preference.sortMode).toBe('manual') + expect(json.preference.version).toBe(2) + }) + + it('PUT returns 409 and latest preference on version mismatch', async () => { + const engine = { + setSessionSortPreference: () => ({ + result: 'version-mismatch', + preference: { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + }, + version: 3, + updatedAt: 200 + } + } satisfies SetSessionSortPreferenceResult) + } as unknown as SyncEngine + + const app = createApp({ userId: 1, namespace: 'alpha', engine }) + + const response = await app.request('http://localhost/api/preferences/session-sort', { + method: 'PUT', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + sortMode: 'manual', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + expectedVersion: 1 + }) + }) + + expect(response.status).toBe(409) + const json = await response.json() as { + error: string + preference: { + version: number + } + } + expect(json.error).toBe('version_mismatch') + expect(json.preference.version).toBe(3) + }) + + it('PUT validates body', async () => { + const engine = { + setSessionSortPreference: () => ({ result: 'error' }) + } as unknown as SyncEngine + const app = createApp({ userId: 1, namespace: 'alpha', engine }) + + const response = await app.request('http://localhost/api/preferences/session-sort', { + method: 'PUT', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + sortMode: 'manual', + expectedVersion: 0 + }) + }) + + expect(response.status).toBe(400) + }) + + it('PUT uses auth scope from middleware', async () => { + const captured: { + userId?: number + namespace?: string + } = {} + const engine = { + setSessionSortPreference: (userId: number, namespace: string) => { + captured.userId = userId + captured.namespace = namespace + return { + result: 'success', + preference: { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + version: 2, + updatedAt: 0 + } + } satisfies SetSessionSortPreferenceResult + } + } as unknown as SyncEngine + + const app = createApp({ userId: 99, namespace: 'team-1', engine }) + + const response = await app.request('http://localhost/api/preferences/session-sort', { + method: 'PUT', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + expectedVersion: 1 + }) + }) + + expect(response.status).toBe(200) + expect(captured).toEqual({ userId: 99, namespace: 'team-1' }) + }) +}) diff --git a/hub/src/web/routes/preferences.ts b/hub/src/web/routes/preferences.ts new file mode 100644 index 000000000..672654ccf --- /dev/null +++ b/hub/src/web/routes/preferences.ts @@ -0,0 +1,59 @@ +import { SessionManualOrderSchema, SessionSortModeSchema } from '@hapi/protocol/schemas' +import { Hono } from 'hono' +import { z } from 'zod' + +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const setSessionSortPreferenceSchema = z.object({ + sortMode: SessionSortModeSchema, + manualOrder: SessionManualOrderSchema, + expectedVersion: z.number().int().positive().optional() +}) + +export function createPreferencesRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/preferences/session-sort', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const userId = c.get('userId') + const namespace = c.get('namespace') + const preference = engine.getSessionSortPreference(userId, namespace) + + return c.json({ preference }) + }) + + app.put('/preferences/session-sort', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const body = await c.req.json().catch(() => null) + const parsed = setSessionSortPreferenceSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const userId = c.get('userId') + const namespace = c.get('namespace') + const result = engine.setSessionSortPreference(userId, namespace, parsed.data) + + if (result.result === 'error') { + return c.json({ error: 'Failed to save session sort preference' }, 500) + } + + if (result.result === 'version-mismatch') { + return c.json({ error: 'version_mismatch', preference: result.preference }, 409) + } + + return c.json({ preference: result.preference }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 08800fc72..4f5137b12 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -18,6 +18,7 @@ import { createMachinesRoutes } from './routes/machines' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createPushRoutes } from './routes/push' +import { createPreferencesRoutes } from './routes/preferences' import { createVoiceRoutes } from './routes/voice' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' @@ -77,7 +78,7 @@ function createWebApp(options: { const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins const corsMiddleware = cors({ origin: corsOriginOption, - allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowHeaders: ['authorization', 'content-type'] }) app.use('/api/*', corsMiddleware) @@ -96,6 +97,7 @@ function createWebApp(options: { app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createGitRoutes(options.getSyncEngine)) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) + app.route('/api', createPreferencesRoutes(options.getSyncEngine)) app.route('/api', createVoiceRoutes()) // Skip static serving in relay mode, show helpful message on root diff --git a/package.json b/package.json index 8cc9e91a7..8c0b05347 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:cli": "cd cli && bun run test", "test:hub": "cd hub && bun run test", "test:web": "cd web && bun run test", + "test:e2e:web": "cd web && bun run test:e2e", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/scripts/rebuild-and-restart.sh b/scripts/rebuild-and-restart.sh new file mode 100755 index 000000000..41715edf0 --- /dev/null +++ b/scripts/rebuild-and-restart.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Rebuild from current branch, install binary, restart systemd services. +# +# Usage: +# ./scripts/rebuild-and-restart.sh # full rebuild (web + cli) +# ./scripts/rebuild-and-restart.sh --quick # skip web rebuild if dist/ exists +# +# Optional env overrides: +# INSTALL_DIR=$HOME/.local/bin + +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" +QUICK=0 + +for arg in "$@"; do + case "$arg" in + --quick) QUICK=1 ;; + *) echo "unknown flag: $arg" >&2; exit 1 ;; + esac +done + +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +detect_target() { + local os arch + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + case "${os}-${arch}" in + linux-x86_64) echo "bun-linux-x64" ;; + linux-aarch64|linux-arm64) echo "bun-linux-arm64" ;; + darwin-x86_64) echo "bun-darwin-x64" ;; + darwin-arm64) echo "bun-darwin-arm64" ;; + *) + echo "error: unsupported platform: ${os}-${arch}" >&2 + exit 1 + ;; + esac +} + +target="$(detect_target)" +branch="$(git branch --show-current)" +echo "==> rebuilding from branch: ${branch}" + +if [[ "$QUICK" == "1" ]] && [[ -f "web/dist/index.html" ]]; then + echo "==> --quick: skipping web build (using existing dist/)" +else + echo "==> building web" + bun run build:web +fi + +echo "==> generating embedded web assets" +(cd hub && bun run generate:embedded-web-assets) + +echo "==> building executable" +(cd cli && bun run build:exe:allinone) + +binary_path="cli/dist-exe/${target}/hapi" +if [[ ! -x "$binary_path" ]]; then + echo "error: binary not found: ${binary_path}" >&2 + exit 1 +fi + +echo "==> installing to ${INSTALL_DIR}/hapi" +mkdir -p "$INSTALL_DIR" +install -m755 "$binary_path" "${INSTALL_DIR}/hapi" + +echo "==> restarting services" +systemctl --user restart hapi-hub.service +sleep 2 +systemctl --user restart hapi-runner.service + +echo "" +echo "done." +echo "binary: ${INSTALL_DIR}/hapi" +echo "version: $("${INSTALL_DIR}/hapi" --version 2>/dev/null || echo 'unknown')" +echo "hub: $(systemctl --user is-active hapi-hub.service)" +echo "runner: $(systemctl --user is-active hapi-runner.service)" diff --git a/scripts/update-local-hapi.sh b/scripts/update-local-hapi.sh new file mode 100755 index 000000000..4750533c1 --- /dev/null +++ b/scripts/update-local-hapi.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Keep local forked main updated from upstream, build, install local binary, push fork. +# +# Defaults match this repo's current remote layout: +# - origin = upstream (tiann/hapi) +# - fork = your fork +# +# Optional env overrides: +# UPSTREAM_REMOTE=origin +# FORK_REMOTE=fork +# MAIN_BRANCH=main +# INSTALL_DIR=$HOME/.local/bin +# RUN_TESTS=0 # set to 1 to run typecheck + web tests before build + +UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-origin}" +FORK_REMOTE="${FORK_REMOTE:-fork}" +MAIN_BRANCH="${MAIN_BRANCH:-main}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" +RUN_TESTS="${RUN_TESTS:-0}" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: missing required command: $1" >&2 + exit 1 + fi +} + +detect_target() { + local os arch + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + + case "${os}-${arch}" in + linux-x86_64) echo "bun-linux-x64" ;; + linux-aarch64|linux-arm64) echo "bun-linux-arm64" ;; + darwin-x86_64) echo "bun-darwin-x64" ;; + darwin-arm64) echo "bun-darwin-arm64" ;; + *) + echo "error: unsupported platform: ${os}-${arch}" >&2 + echo "supported: linux/darwin on x64/arm64" >&2 + exit 1 + ;; + esac +} + +require_cmd git +require_cmd bun +require_cmd install + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "error: run this script inside the hapi git repo" >&2 + exit 1 +fi + +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "error: working tree not clean; commit/stash first" >&2 + exit 1 +fi + +echo "==> checkout ${MAIN_BRANCH}" +git checkout "${MAIN_BRANCH}" + +echo "==> fetch remotes" +git fetch "${UPSTREAM_REMOTE}" "${MAIN_BRANCH}" +git fetch "${FORK_REMOTE}" "${MAIN_BRANCH}" || true + +echo "==> merge upstream changes" +git merge "${UPSTREAM_REMOTE}/${MAIN_BRANCH}" + +echo "==> install dependencies" +bun install + +if [[ "${RUN_TESTS}" == "1" ]]; then + echo "==> run validation" + bun run typecheck + bun run test:web +fi + +echo "==> build single binary" +bun run build:single-exe + +target="$(detect_target)" +binary_path="cli/dist-exe/${target}/hapi" + +if [[ ! -x "${binary_path}" ]]; then + echo "error: built binary not found: ${binary_path}" >&2 + exit 1 +fi + +echo "==> install binary to ${INSTALL_DIR}/hapi" +mkdir -p "${INSTALL_DIR}" +install -m755 "${binary_path}" "${INSTALL_DIR}/hapi" + +echo "==> push ${MAIN_BRANCH} to fork" +git push "${FORK_REMOTE}" "${MAIN_BRANCH}:${MAIN_BRANCH}" + +echo "" +echo "done." +echo "binary: ${INSTALL_DIR}/hapi" +echo "version: $("${INSTALL_DIR}/hapi" --version || true)" diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 76b0fb00c..c3d1068b9 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -35,6 +35,9 @@ export const PERMISSION_MODES = [ ] as const export type PermissionMode = typeof PERMISSION_MODES[number] +export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const +export type ModelMode = typeof MODEL_MODES[number] + export const CLAUDE_MODEL_PRESETS = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const export type ClaudeModelPreset = typeof CLAUDE_MODEL_PRESETS[number] @@ -82,6 +85,11 @@ export type PermissionModeOption = { tone: PermissionModeTone } +export type ModelModeOption = { + mode: ModelMode + label: string +} + export type CodexCollaborationModeOption = { mode: CodexCollaborationMode label: string @@ -94,6 +102,12 @@ export const CLAUDE_MODEL_LABELS: Record = { 'opus[1m]': 'Opus 1M' } +export const MODEL_MODE_LABELS: Record = { + default: 'Default', + sonnet: 'Sonnet', + opus: 'Opus' +} + export const CODEX_COLLABORATION_MODE_LABELS: Record = { default: 'Default', plan: 'Plan' @@ -152,6 +166,28 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: return getPermissionModesForFlavor(flavor).includes(mode) } +export function getModelModesForFlavor(flavor?: string | null): readonly ModelMode[] { + if (flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' || flavor === 'cursor') { + return [] + } + return MODEL_MODES +} + +export function isModelModeAllowedForFlavor(mode: ModelMode, flavor?: string | null): boolean { + return getModelModesForFlavor(flavor).includes(mode) +} + +export function getModelModeLabel(mode: ModelMode): string { + return MODEL_MODE_LABELS[mode] +} + +export function getModelModeOptionsForFlavor(flavor?: string | null): ModelModeOption[] { + return getModelModesForFlavor(flavor).map((mode) => ({ + mode, + label: getModelModeLabel(mode) + })) +} + export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption[] { return CODEX_COLLABORATION_MODES.map((mode) => ({ mode, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 52ec83737..d630942b1 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,8 +1,28 @@ import { z } from 'zod' -import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' +import { CODEX_COLLABORATION_MODES, MODEL_MODES, PERMISSION_MODES } from './modes' export const PermissionModeSchema = z.enum(PERMISSION_MODES) +export const ModelModeSchema = z.enum(MODEL_MODES) export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) +export const SessionSortModeSchema = z.enum(['auto', 'manual']) + +export type SessionSortMode = z.infer + +export const SessionManualOrderSchema = z.object({ + groupOrder: z.array(z.string().max(256)).max(500), + sessionOrder: z.record(z.string().max(256), z.array(z.string().max(128)).max(200)) +}).strict() + +export type SessionManualOrder = z.infer + +export const SessionSortPreferenceSchema = z.object({ + sortMode: SessionSortModeSchema, + manualOrder: SessionManualOrderSchema, + version: z.number().int().positive(), + updatedAt: z.number().int().nonnegative() +}).strict() + +export type SessionSortPreference = z.infer const MetadataSummarySchema = z.object({ text: z.string(), @@ -177,6 +197,7 @@ export const SessionSchema = z.object({ model: z.string().nullable(), effort: z.string().nullable(), permissionMode: PermissionModeSchema.optional(), + modelMode: ModelModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() }) @@ -236,6 +257,13 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ status: z.string(), subscriptionId: z.string().optional() }).optional() + }), + SessionEventBaseSchema.extend({ + type: z.literal('session-sort-preference-updated'), + data: z.object({ + userId: z.number().int().nonnegative(), + version: z.number().int().positive() + }) }) ]) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index e717a57dd..51d93aae1 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,3 +1,4 @@ +import type { ModelMode, PermissionMode } from './modes' import type { Session, WorktreeMetadata } from './schemas' export type SessionSummaryMetadata = { @@ -20,6 +21,8 @@ export type SessionSummary = { pendingRequestsCount: number model: string | null effort: string | null + permissionMode?: PermissionMode + modelMode?: ModelMode } export function toSessionSummary(session: Session): SessionSummary { @@ -49,6 +52,8 @@ export function toSessionSummary(session: Session): SessionSummary { todoProgress, pendingRequestsCount, model: session.model, - effort: session.effort + effort: session.effort, + permissionMode: session.permissionMode, + modelMode: session.modelMode } } diff --git a/shared/src/types.ts b/shared/src/types.ts index 37333a60e..0397d262e 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -5,6 +5,9 @@ export type { AttachmentMetadata, DecryptedMessage, Metadata, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, Session, SyncEvent, TeamMember, @@ -28,6 +31,8 @@ export type { GeminiPermissionMode, OpencodePermissionMode, ClaudeModelPreset, + ModelMode, + ModelModeOption, PermissionMode, PermissionModeOption, PermissionModeTone diff --git a/web/.gitignore b/web/.gitignore index 9ba881afd..670b52ffe 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ dev-dist/ +playwright-report/ +test-results/ diff --git a/web/README.md b/web/README.md index 383eee1f5..037dcf3c9 100644 --- a/web/README.md +++ b/web/README.md @@ -140,6 +140,41 @@ bun run build:web The built assets land in `web/dist` and are served by hapi-hub. The single executable can embed these assets. +## E2E (Playwright) + +Playwright covers: + +- `web/e2e/session-sort.backend.e2e.spec.ts` — session-sort backend persistence. +- `web/e2e/session-metadata.ui.e2e.spec.ts` — session list and header metadata chip rendering. + +Shared env knobs: `HAPI_E2E_BASE_URL` (default `http://127.0.0.1:3906`), `HAPI_E2E_CLI_TOKEN` (default `pw-test-token`), and `HAPI_E2E_RUN_ID` (defaults to a random suffix) namespace test sessions across runs. + +Install browser once: + +```bash +cd web +bun run test:e2e:install +``` + +Run all Playwright tests: + +```bash +cd web +bun run test:e2e +``` + +Run only session-sort flow: + +```bash +cd web +bun run test:e2e:session-sort +``` + +Notes: + +- Seeded CLI sessions in these tests must include `metadata.host`; otherwise metadata schema parsing can drop fields and UI grouping/selectors become unstable. +- `page.reload({ waitUntil: 'networkidle' })` can hang when SSE stays active. Prefer `waitUntil: 'domcontentloaded'` for authenticated app reload assertions. + ## Standalone hosting You can host `web/dist` on a static host (GitHub Pages, Cloudflare Pages) and point it at any hapi hub: diff --git a/web/e2e/session-metadata.ui.e2e.spec.ts b/web/e2e/session-metadata.ui.e2e.spec.ts new file mode 100644 index 000000000..9df48c4f5 --- /dev/null +++ b/web/e2e/session-metadata.ui.e2e.spec.ts @@ -0,0 +1,83 @@ +import { expect, test, type Page } from '@playwright/test' + +const BASE_URL = process.env.HAPI_E2E_BASE_URL ?? 'http://127.0.0.1:3906' +const BASE_TOKEN = process.env.HAPI_E2E_CLI_TOKEN ?? 'pw-test-token' +const RUN_ID = process.env.HAPI_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + +function token(namespaceSuffix: string): string { + return `${BASE_TOKEN}:session-metadata-${RUN_ID}-${namespaceSuffix}` +} + +async function login(page: Page, accessToken: string): Promise { + await page.goto(BASE_URL, { waitUntil: 'networkidle' }) + await page.getByPlaceholder('Access token').fill(accessToken) + await page.getByRole('button', { name: 'Sign In' }).click() + await expect(page.getByPlaceholder('Access token')).toHaveCount(0, { timeout: 15_000 }) + await expect(page.locator('.session-list-item').first()).toBeVisible({ timeout: 15_000 }) +} + +async function createCliSession( + accessToken: string, + tag: string, + name: string, + path: string, + machineId: string +): Promise { + const response = await fetch(`${BASE_URL}/cli/sessions`, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + tag, + metadata: { + name, + path, + host: 'pw-host', + machineId, + flavor: 'codex', + worktree: { + basePath: '/work/repo', + branch: 'feature/chips', + name: 'feature-chips' + } + }, + agentState: null, + model: 'gpt-5.4', + effort: 'very-high' + }) + }) + expect(response.status).toBe(200) + const json = await response.json() as { session: { id: string } } + return json.session.id +} + +test('session metadata chips render in list and header', async ({ page }) => { + const accessToken = token('chips') + + const activeSessionId = await createCliSession(accessToken, 's-active', 'Active Session', '/work/repo/project-a', 'm1') + await createCliSession(accessToken, 's-inactive', 'Inactive Session', '/work/repo/project-a', 'm1') + + await login(page, accessToken) + + await page.getByRole('button', { + name: /work\/repo/i + }).first().click() + + const activeRow = page.locator('.session-list-item', { hasText: 'Active Session' }).first() + await expect(activeRow).toContainText('codex') + await expect(activeRow).toContainText('gpt-5.4') + await expect(activeRow).toContainText('feature/chips') + + await page.goto(`${BASE_URL}/sessions/${activeSessionId}`, { waitUntil: 'domcontentloaded' }) + + const headerTitle = page.locator('div.truncate.font-semibold').first() + await expect(headerTitle).toHaveText('Active Session') + + const headerMeta = headerTitle.locator('xpath=following-sibling::div[1]') + await expect(headerMeta).toContainText('codex') + await expect(headerMeta).toContainText('gpt-5.4') + await expect(headerMeta).toContainText('Very High') + await expect(headerMeta).toContainText('feature/chips') +}) diff --git a/web/e2e/session-sort.backend.e2e.spec.ts b/web/e2e/session-sort.backend.e2e.spec.ts new file mode 100644 index 000000000..e9d1869c1 --- /dev/null +++ b/web/e2e/session-sort.backend.e2e.spec.ts @@ -0,0 +1,289 @@ +import { expect, test, type Page } from '@playwright/test' + +const BASE_URL = process.env.HAPI_E2E_BASE_URL ?? 'http://127.0.0.1:3906' +const BASE_TOKEN = process.env.HAPI_E2E_CLI_TOKEN ?? 'pw-test-token' +const RUN_ID = process.env.HAPI_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + +function token(namespaceSuffix: string): string { + return `${BASE_TOKEN}:session-sort-${RUN_ID}-${namespaceSuffix}` +} + +async function login(page: Page, accessToken: string): Promise { + await page.goto(BASE_URL, { waitUntil: 'networkidle' }) + await page.getByPlaceholder('Access token').fill(accessToken) + await page.getByRole('button', { name: 'Sign In' }).click() + await expect(page.getByText(/sessions in .* projects/i)).toBeVisible({ timeout: 15_000 }) +} + +async function expandAllGroups(page: Page): Promise { + const headers = page.locator('button').filter({ has: page.locator('span.font-semibold') }) + const count = await headers.count() + for (let i = 0; i < count; i += 1) { + await headers.nth(i).click() + } + await expect(page.locator('.session-list-item').first()).toBeVisible({ timeout: 10_000 }) +} + +async function sessionRowTexts(page: Page): Promise { + return page.locator('.session-list-item').allTextContents() +} + +function findIndex(rows: string[], text: string): number { + return rows.findIndex((row) => row.includes(text)) +} + +async function authJwt(accessToken: string): Promise { + const response = await fetch(`${BASE_URL}/api/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ accessToken }) + }) + expect(response.status).toBe(200) + const json = await response.json() as { token: string } + return json.token +} + +type SessionSortPreference = { + sortMode: 'auto' | 'manual' + manualOrder: { + groupOrder: string[] + sessionOrder: Record + } + version: number +} + +async function getPreference(jwt: string): Promise { + const response = await fetch(`${BASE_URL}/api/preferences/session-sort`, { + headers: { authorization: `Bearer ${jwt}` } + }) + expect(response.status).toBe(200) + const json = await response.json() as { preference: SessionSortPreference } + return json.preference +} + +async function putPreference( + jwt: string, + payload: { + sortMode: 'auto' | 'manual' + manualOrder: { + groupOrder: string[] + sessionOrder: Record + } + expectedVersion?: number + } +): Promise<{ status: number; json: unknown }> { + const response = await fetch(`${BASE_URL}/api/preferences/session-sort`, { + method: 'PUT', + headers: { + authorization: `Bearer ${jwt}`, + 'content-type': 'application/json' + }, + body: JSON.stringify(payload) + }) + const json = await response.json().catch(() => ({})) + return { status: response.status, json } +} + +async function createCliSession( + accessToken: string, + tag: string, + name: string, + path: string, + machineId: string +): Promise { + const response = await fetch(`${BASE_URL}/cli/sessions`, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + tag, + metadata: { + name, + path, + host: 'pw-host', + machineId, + flavor: 'claude' + }, + agentState: null + }) + }) + expect(response.status).toBe(200) +} + +async function seedBaseSessions(accessToken: string): Promise { + await createCliSession(accessToken, 's-alpha', 'Alpha', '/work/repo/a', 'm1') + await createCliSession(accessToken, 's-beta', 'Beta', '/work/repo/a', 'm1') + await createCliSession(accessToken, 's-gamma', 'Gamma', '/work/repo/b', 'm1') + await createCliSession(accessToken, 's-delta', 'Delta', '/work/repo/b', 'm1') +} + +async function resetPreferenceToAuto(accessToken: string): Promise { + const jwt = await authJwt(accessToken) + const preference = await getPreference(jwt) + const result = await putPreference(jwt, { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + expectedVersion: preference.version + }) + expect(result.status).toBe(200) +} + +async function bootstrapNamespace(accessToken: string): Promise { + await seedBaseSessions(accessToken) + await resetPreferenceToAuto(accessToken) +} + +test.describe.configure({ mode: 'serial' }) +test.setTimeout(180_000) + +test('session sort: manual mode UI flow + persistence + stale IDs + new session append', async ({ page }) => { + const accessToken = token('manual') + await bootstrapNamespace(accessToken) + + await login(page, accessToken) + await expandAllGroups(page) + + await expect(page.locator('button[title="Sort: automatic"]')).toBeVisible() + await page.locator('button[title="Sort: automatic"]').click() + await expect(page.locator('button[title="Sort: manual"]')).toBeVisible() + + const alphaRow = page.locator('.session-list-item', { hasText: 'Alpha' }).first() + await alphaRow.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Move Up' }).click() + + const afterMoveRows = await sessionRowTexts(page) + expect(findIndex(afterMoveRows, 'Alpha')).toBeLessThan(findIndex(afterMoveRows, 'Beta')) + + await page.locator('.session-list-item', { hasText: 'Alpha' }).first().click({ button: 'right' }) + await expect(page.getByRole('menuitem', { name: 'Move Up' })).toBeDisabled() + await page.keyboard.press('Escape') + + const initialHeaders = await page.locator('span.font-semibold').allTextContents() + const initialGroups = initialHeaders.filter((text) => text.includes('repo/')) + expect(initialGroups.length).toBeGreaterThan(1) + const movedGroup = initialGroups[1] + + const movedGroupHeader = page.locator('button', { + has: page.locator('span.font-semibold', { hasText: movedGroup }) + }).first() + await movedGroupHeader.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Move Up' }).click() + + await expect.poll(async () => { + const headers = await page.locator('span.font-semibold').allTextContents() + const groupNames = headers.filter((text) => text.includes('repo/')) + return groupNames[0] ?? '' + }).toBe(movedGroup) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.locator('button[title="Sort: manual"]')).toBeVisible({ timeout: 15_000 }) + await expandAllGroups(page) + + const afterReloadRows = await sessionRowTexts(page) + expect(findIndex(afterReloadRows, 'Alpha')).toBeLessThan(findIndex(afterReloadRows, 'Beta')) + + const jwt = await authJwt(accessToken) + const preference = await getPreference(jwt) + const stalePut = await putPreference(jwt, { + sortMode: 'manual', + expectedVersion: preference.version, + manualOrder: { + groupOrder: ['fake-group', ...preference.manualOrder.groupOrder], + sessionOrder: { + ...preference.manualOrder.sessionOrder, + 'fake-group': ['fake-session'] + } + } + }) + expect(stalePut.status).toBe(200) + + await expect(page.locator('.session-list-item', { hasText: 'Alpha' }).first()).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('.session-list-item', { hasText: 'Beta' }).first()).toBeVisible() + + await createCliSession(accessToken, 's-epsilon', 'Epsilon', '/work/repo/a', 'm1') + + await expect(page.locator('.session-list-item', { hasText: 'Epsilon' }).first()).toBeVisible({ timeout: 15_000 }) + const withNewRows = await sessionRowTexts(page) + const alphaIndex = findIndex(withNewRows, 'Alpha') + const betaIndex = findIndex(withNewRows, 'Beta') + const epsilonIndex = findIndex(withNewRows, 'Epsilon') + expect(epsilonIndex).toBeGreaterThan(alphaIndex) + expect(epsilonIndex).toBeGreaterThan(betaIndex) +}) + +test('session sort: SSE sync across two clients', async ({ browser }) => { + const accessToken = token('sse') + await bootstrapNamespace(accessToken) + await createCliSession(accessToken, 's-epsilon', 'Epsilon', '/work/repo/a', 'm1') + + const contextA = await browser.newContext() + const contextB = await browser.newContext() + const pageA = await contextA.newPage() + const pageB = await contextB.newPage() + + await login(pageA, accessToken) + await login(pageB, accessToken) + await expandAllGroups(pageA) + await expandAllGroups(pageB) + + await pageA.locator('button[title="Sort: automatic"]').click() + await expect(pageA.locator('button[title="Sort: manual"]')).toBeVisible() + await expect(pageB.locator('button[title="Sort: manual"]')).toBeVisible({ timeout: 15_000 }) + + const beforeRows = await sessionRowTexts(pageA) + const repoAOrder = ['Alpha', 'Beta', 'Epsilon'] + .map((name) => ({ name, index: findIndex(beforeRows, name) })) + .filter((entry) => entry.index >= 0) + .sort((a, b) => a.index - b.index) + expect(repoAOrder.length).toBeGreaterThan(1) + + const moveTarget = repoAOrder[1] + const expectedAbove = repoAOrder[0] + + await pageA.locator('.session-list-item', { hasText: moveTarget.name }).first().click({ button: 'right' }) + await pageA.getByRole('menuitem', { name: 'Move Up' }).click() + + await expect.poll(async () => { + const rows = await sessionRowTexts(pageB) + return { + moved: findIndex(rows, moveTarget.name), + above: findIndex(rows, expectedAbove.name) + } + }, { timeout: 20_000 }).toEqual(expect.objectContaining({ + moved: expect.any(Number), + above: expect.any(Number) + })) + + const rowsB = await sessionRowTexts(pageB) + expect(findIndex(rowsB, moveTarget.name)).toBeLessThan(findIndex(rowsB, expectedAbove.name)) + + await contextA.close() + await contextB.close() +}) + +test('session sort: API conflict path returns 409 version_mismatch', async () => { + const accessToken = token('conflict') + await bootstrapNamespace(accessToken) + + const jwt = await authJwt(accessToken) + const preference = await getPreference(jwt) + + const payload = { + sortMode: preference.sortMode, + manualOrder: preference.manualOrder, + expectedVersion: preference.version + } + + const first = await putPreference(jwt, payload) + expect(first.status).toBe(200) + + const second = await putPreference(jwt, payload) + expect(second.status).toBe(409) + expect((second.json as { error?: string }).error).toBe('version_mismatch') + expect(((second.json as { preference: SessionSortPreference }).preference.version)).toBeGreaterThan(preference.version) +}) diff --git a/web/package.json b/web/package.json index e22d39631..e719e5eb4 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,9 @@ "build": "vite build && cp dist/index.html dist/404.html", "typecheck": "tsc --noEmit", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "BUN_BIN=${BUN_BIN:-$(command -v bun || echo $HOME/.bun/bin/bun)} playwright test --config=playwright.config.ts", + "test:e2e:install": "playwright install chromium" }, "dependencies": { "@assistant-ui/react": "^0.11.53", @@ -44,6 +46,7 @@ "workbox-window": "^7.4.0" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@types/react": "^19.2.7", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..b19469a85 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from '@playwright/test' + +const port = Number(process.env.HAPI_E2E_PORT ?? '3906') +const baseUrl = process.env.HAPI_E2E_BASE_URL ?? `http://127.0.0.1:${port}` +const hapiHome = process.env.HAPI_E2E_HAPI_HOME ?? `/tmp/hapi-playwright-${port}` +const cliApiToken = process.env.HAPI_E2E_CLI_TOKEN ?? 'pw-test-token' +const bunBin = process.env.BUN_BIN ?? 'bun' + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'` +} + +const buildCommand = [ + `${shellQuote(bunBin)} run build`, + `rm -rf ${shellQuote(hapiHome)}`, + `mkdir -p ${shellQuote(hapiHome)}`, + [ + `CLI_API_TOKEN=${shellQuote(cliApiToken)}`, + `HAPI_HOME=${shellQuote(hapiHome)}`, + 'HAPI_LISTEN_HOST=127.0.0.1', + `HAPI_LISTEN_PORT=${port}`, + `${shellQuote(bunBin)} run --cwd ../hub src/index.ts` + ].join(' ') +].join(' && ') + +export default defineConfig({ + testDir: './e2e', + timeout: 180_000, + expect: { + timeout: 20_000 + }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['github'], ['line']] : 'line', + use: { + baseURL: baseUrl, + trace: 'on-first-retry', + locale: 'en-US' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ], + webServer: { + command: buildCommand, + url: `${baseUrl}/health`, + timeout: 120_000, + reuseExistingServer: false + } +}) diff --git a/web/src/App.tsx b/web/src/App.tsx index daf6531da..ffbf40899 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' -import { initializeTheme } from '@/hooks/useTheme' +import { initializeTheme, useTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' import { useServerUrl } from '@/hooks/useServerUrl' @@ -41,6 +41,7 @@ export function App() { } function AppInner() { + useTheme() const { t } = useTranslation() const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) @@ -197,6 +198,7 @@ function AppInner() { startSync() } const invalidations = [ + queryClient.invalidateQueries({ queryKey: queryKeys.sessionSortPreference }), queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), ...(selectedSessionId ? [ queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 163eb206d..08d3c7f09 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,6 +14,9 @@ import type { PushSubscriptionPayload, PushUnsubscribePayload, PushVapidPublicKeyResponse, + SessionSortPreferenceResponse, + SetSessionSortPreferencePayload, + SetSessionSortPreferenceResult, SlashCommandsResponse, SkillsResponse, SpawnResponse, @@ -116,7 +119,9 @@ export class ApiClient { if (!res.ok) { const body = await res.text().catch(() => '') - throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`) + const code = parseErrorCode(body) + const detail = body ? `: ${body}` : '' + throw new ApiError(`HTTP ${res.status} ${res.statusText}${detail}`, res.status, code, body || undefined) } return await res.json() as T @@ -160,6 +165,42 @@ export class ApiClient { return await this.request('/api/sessions') } + async getSessionSortPreference(): Promise { + return await this.request('/api/preferences/session-sort') + } + + async setSessionSortPreference( + payload: SetSessionSortPreferencePayload + ): Promise { + try { + const response = await this.request('/api/preferences/session-sort', { + method: 'PUT', + body: JSON.stringify(payload) + }) + return { + status: 'success', + preference: response.preference + } + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 409 || !error.body) { + throw error + } + + try { + const parsed = JSON.parse(error.body) as { error?: string; preference?: SessionSortPreferenceResponse['preference'] } + if (parsed.error === 'version_mismatch' && parsed.preference) { + return { + status: 'version-mismatch', + preference: parsed.preference + } + } + } catch { + } + + throw error + } + } + async getPushVapidPublicKey(): Promise { return await this.request('/api/push/vapid-public-key') } diff --git a/web/src/components/GroupActionMenu.tsx b/web/src/components/GroupActionMenu.tsx new file mode 100644 index 000000000..91cfc98d8 --- /dev/null +++ b/web/src/components/GroupActionMenu.tsx @@ -0,0 +1,201 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties +} from 'react' + +import { ArrowUpIcon, ArrowDownIcon } from '@/components/icons/SortIcons' +import { useTranslation } from '@/lib/use-translation' + +type GroupActionMenuProps = { + isOpen: boolean + onClose: () => void + onMoveUp: () => void + onMoveDown: () => void + canMoveUp: boolean + canMoveDown: boolean + anchorPoint: { x: number; y: number } + menuId?: string +} + +type MenuPosition = { + top: number + left: number + transformOrigin: string +} + +export function GroupActionMenu(props: GroupActionMenuProps) { + const { t } = useTranslation() + const { + isOpen, + onClose, + onMoveUp, + onMoveDown, + canMoveUp, + canMoveDown, + anchorPoint, + menuId + } = props + const menuRef = useRef(null) + const [menuPosition, setMenuPosition] = useState(null) + const internalId = useId() + const resolvedMenuId = menuId ?? `group-action-menu-${internalId}` + const headingId = `${resolvedMenuId}-heading` + + const updatePosition = useCallback(() => { + const menuEl = menuRef.current + if (!menuEl) return + + const menuRect = menuEl.getBoundingClientRect() + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const padding = 8 + const gap = 8 + + const spaceBelow = viewportHeight - anchorPoint.y + const spaceAbove = anchorPoint.y + const openAbove = spaceBelow < menuRect.height + gap && spaceAbove > spaceBelow + + let top = openAbove ? anchorPoint.y - menuRect.height - gap : anchorPoint.y + gap + let left = anchorPoint.x - menuRect.width / 2 + const transformOrigin = openAbove ? 'bottom center' : 'top center' + + top = Math.min(Math.max(top, padding), viewportHeight - menuRect.height - padding) + left = Math.min(Math.max(left, padding), viewportWidth - menuRect.width - padding) + + setMenuPosition({ top, left, transformOrigin }) + }, [anchorPoint]) + + useLayoutEffect(() => { + if (!isOpen) return + updatePosition() + }, [isOpen, updatePosition]) + + useEffect(() => { + if (!isOpen) { + setMenuPosition(null) + return + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node + if (menuRef.current?.contains(target)) return + onClose() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + return + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + const items = menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') + if (!items || items.length === 0) return + const current = document.activeElement as HTMLElement + const index = Array.from(items).indexOf(current) + const next = event.key === 'ArrowDown' + ? items[(index + 1) % items.length] + : items[(index - 1 + items.length) % items.length] + next?.focus() + } + } + + const handleReflow = () => { + updatePosition() + } + + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + window.addEventListener('resize', handleReflow) + window.addEventListener('scroll', handleReflow, true) + + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('resize', handleReflow) + window.removeEventListener('scroll', handleReflow, true) + } + }, [isOpen, onClose, updatePosition]) + + useEffect(() => { + if (!isOpen) return + + const frame = window.requestAnimationFrame(() => { + const firstItem = menuRef.current?.querySelector('[role="menuitem"]:not([disabled])') + firstItem?.focus() + }) + + return () => window.cancelAnimationFrame(frame) + }, [isOpen]) + + if (!isOpen) return null + + const menuStyle: CSSProperties | undefined = menuPosition + ? { + top: menuPosition.top, + left: menuPosition.left, + transformOrigin: menuPosition.transformOrigin + } + : undefined + + const baseItemClassName = + 'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-base transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]' + + return ( +
+
+ {t('group.more')} +
+ +
+ ) +} diff --git a/web/src/components/NewSession/ActionButtons.tsx b/web/src/components/NewSession/ActionButtons.tsx index 00b6b9f81..72292b247 100644 --- a/web/src/components/NewSession/ActionButtons.tsx +++ b/web/src/components/NewSession/ActionButtons.tsx @@ -13,7 +13,7 @@ export function ActionButtons(props: { const { t } = useTranslation() return ( -
+
+ +
+ + ) : null} + - {/* Session info - two lines: title and path */}
{title}
-
- - - {session.metadata?.flavor?.trim() || 'unknown'} - - {modelLabel ? ( - - {t(modelLabel.key)}: {modelLabel.value} - - ) : null} - {worktreeBranch ? ( - {t('session.item.worktree')}: {worktreeBranch} - ) : null} +
+ {metadataItems.map((item, index) => ( + + {index > 0 ? ( + + ) : null} + {item} + + ))}
@@ -155,7 +167,7 @@ export function SessionHeader(props: { void +}) { + const { haptic } = usePlatform() + const longPressHandlers = useLongPress({ + onLongPress: () => { + haptic.impact('medium') + }, + onClick: props.onToggle, + threshold: 500 + }) + + return ( + + ) +} + export function SessionList(props: { sessions: SessionSummary[] onSelect: (sessionId: string) => void @@ -363,14 +400,34 @@ export function SessionList(props: { selectedSessionId?: string | null }) { const { t } = useTranslation() - const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props + const { + renderHeader = true, + api, + selectedSessionId, + machineLabelsById = {} + } = props const groups = useMemo( () => groupSessionsByDirectory(props.sessions), [props.sessions] ) + const displayGroups = groups + const knownSessionIdsRef = useRef>(new Set(props.sessions.map(session => session.id))) const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() ) + const enteringSessionIds = useMemo(() => { + const entering = new Set() + const nextKnownSessionIds = new Set(knownSessionIdsRef.current) + props.sessions.forEach(session => { + if (!nextKnownSessionIds.has(session.id)) { + entering.add(session.id) + } + nextKnownSessionIds.add(session.id) + }) + knownSessionIdsRef.current = nextKnownSessionIds + return entering + }, [props.sessions]) + const isGroupCollapsed = (group: SessionGroup): boolean => { const override = collapseOverrides.get(group.key) if (override !== undefined) return override @@ -401,7 +458,7 @@ export function SessionList(props: { useEffect(() => { if (!selectedSessionId) return setCollapseOverrides(prev => { - const group = groups.find(g => + const group = displayGroups.find(g => g.sessions.some(s => s.id === selectedSessionId) ) if (!group || !prev.has(group.key) || !prev.get(group.key)) return prev @@ -409,13 +466,13 @@ export function SessionList(props: { next.delete(group.key) return next }) - }, [selectedSessionId, groups]) + }, [selectedSessionId, displayGroups]) useEffect(() => { setCollapseOverrides(prev => { if (prev.size === 0) return prev const next = new Map(prev) - const knownGroups = new Set(groups.map(group => group.key)) + const knownGroups = new Set(displayGroups.map(group => group.key)) let changed = false for (const groupKey of next.keys()) { if (!knownGroups.has(groupKey)) { @@ -425,19 +482,19 @@ export function SessionList(props: { } return changed ? next : prev }) - }, [groups]) + }, [displayGroups]) return (
{renderHeader ? (
- {t('sessions.count', { n: props.sessions.length, m: groups.length })} + {t('sessions.count', { n: props.sessions.length, m: displayGroups.length })}
+ toggleGroup(group.key, isCollapsed)} + /> {!isCollapsed ? (
{group.sessions.map((s) => ( @@ -485,9 +521,9 @@ export function SessionList(props: { key={s.id} session={s} onSelect={props.onSelect} - showPath={false} api={api} selected={s.id === selectedSessionId} + animateEnter={enteringSessionIds.has(s.id)} /> ))}
@@ -496,6 +532,7 @@ export function SessionList(props: { ) })}
+
) } diff --git a/web/src/components/icons/SortIcons.tsx b/web/src/components/icons/SortIcons.tsx new file mode 100644 index 000000000..92928ed86 --- /dev/null +++ b/web/src/components/icons/SortIcons.tsx @@ -0,0 +1,84 @@ +export function SortIcon(props: { className?: string }) { + return ( + + + + + + + + ) +} + +export function PinIcon(props: { className?: string }) { + return ( + + + + + + + ) +} + +export function ArrowUpIcon(props: { className?: string }) { + return ( + + + + + ) +} + +export function ArrowDownIcon(props: { className?: string }) { + return ( + + + + + ) +} diff --git a/web/src/hooks/mutations/useSessionSortPreference.test.tsx b/web/src/hooks/mutations/useSessionSortPreference.test.tsx new file mode 100644 index 000000000..aebe043fc --- /dev/null +++ b/web/src/hooks/mutations/useSessionSortPreference.test.tsx @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor, act } from '@testing-library/react' +import type { ReactNode } from 'react' + +import { useSessionSortPreferenceMutation } from './useSessionSortPreference' +import { queryKeys } from '@/lib/query-keys' +import type { SessionSortPreferenceResponse } from '@/types/api' + +function createWrapper(queryClient: QueryClient) { + return function Wrapper(props: { children: ReactNode }) { + return ( + + {props.children} + + ) + } +} + +describe('useSessionSortPreferenceMutation', () => { + it('updates cache optimistically on mutate', async () => { + const queryClient = new QueryClient() + const serverPreference = { + sortMode: 'manual' as const, + manualOrder: { groupOrder: ['g1'], sessionOrder: { g1: ['s1'] } }, + version: 3, + updatedAt: 999 + } + const api = { + setSessionSortPreference: vi.fn(async () => ({ + status: 'success' as const, + preference: serverPreference + })) + } + + queryClient.setQueryData( + queryKeys.sessionSortPreference, + { + preference: { + sortMode: 'auto', + manualOrder: { groupOrder: [], sessionOrder: {} }, + version: 2, + updatedAt: 100 + } + } + ) + + const { result } = renderHook(() => useSessionSortPreferenceMutation(api as never), { + wrapper: createWrapper(queryClient) + }) + + await act(async () => { + await result.current.setSessionSortPreference({ + sortMode: 'manual', + manualOrder: { groupOrder: ['g1'], sessionOrder: { g1: ['s1'] } }, + expectedVersion: 2 + }) + }) + + const cached = queryClient.getQueryData(queryKeys.sessionSortPreference) + expect(cached?.preference.sortMode).toBe('manual') + expect(cached?.preference.version).toBe(3) + }) + + it('rolls back cache on error', async () => { + const queryClient = new QueryClient() + const api = { + setSessionSortPreference: vi.fn(async () => { + throw new Error('Network error') + }) + } + + queryClient.setQueryData( + queryKeys.sessionSortPreference, + { + preference: { + sortMode: 'auto', + manualOrder: { groupOrder: [], sessionOrder: {} }, + version: 1, + updatedAt: 100 + } + } + ) + + const { result } = renderHook(() => useSessionSortPreferenceMutation(api as never), { + wrapper: createWrapper(queryClient) + }) + + await act(async () => { + try { + await result.current.setSessionSortPreference({ + sortMode: 'manual', + manualOrder: { groupOrder: ['g1'], sessionOrder: {} }, + expectedVersion: 1 + }) + } catch { + // expected + } + }) + + await waitFor(() => { + const cached = queryClient.getQueryData(queryKeys.sessionSortPreference) + expect(cached?.preference.sortMode).toBe('auto') + expect(cached?.preference.version).toBe(1) + }) + }) + + it('throws when API is not available', async () => { + const queryClient = new QueryClient() + + const { result } = renderHook(() => useSessionSortPreferenceMutation(null), { + wrapper: createWrapper(queryClient) + }) + + await expect( + act(async () => { + await result.current.setSessionSortPreference({ + sortMode: 'manual', + manualOrder: { groupOrder: [], sessionOrder: {} } + }) + }) + ).rejects.toThrow('API unavailable') + }) +}) diff --git a/web/src/hooks/mutations/useSessionSortPreference.ts b/web/src/hooks/mutations/useSessionSortPreference.ts new file mode 100644 index 000000000..c315a0d27 --- /dev/null +++ b/web/src/hooks/mutations/useSessionSortPreference.ts @@ -0,0 +1,68 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { ApiClient } from '@/api/client' +import type { + SessionSortPreference, + SessionSortPreferenceResponse, + SetSessionSortPreferencePayload, + SetSessionSortPreferenceResult +} from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useSessionSortPreferenceMutation(api: ApiClient | null): { + setSessionSortPreference: (payload: SetSessionSortPreferencePayload) => Promise + isPending: boolean +} { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: async (payload: SetSessionSortPreferencePayload) => { + if (!api) { + throw new Error('API unavailable') + } + + return await api.setSessionSortPreference(payload) + }, + onMutate: async (payload) => { + await queryClient.cancelQueries({ queryKey: queryKeys.sessionSortPreference }) + const previous = queryClient.getQueryData(queryKeys.sessionSortPreference) + + const previousPreference = previous?.preference + if (previousPreference) { + const optimisticPreference: SessionSortPreference = { + sortMode: payload.sortMode, + manualOrder: payload.manualOrder, + version: payload.expectedVersion !== undefined + ? payload.expectedVersion + 1 + : previousPreference.version + 1, + updatedAt: Date.now() + } + + queryClient.setQueryData( + queryKeys.sessionSortPreference, + { preference: optimisticPreference } + ) + } + + return { previous } + }, + onError: (_error, _payload, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.sessionSortPreference, context.previous) + } + }, + onSuccess: (result) => { + queryClient.setQueryData( + queryKeys.sessionSortPreference, + { + preference: result.preference + } + ) + } + }) + + return { + setSessionSortPreference: mutation.mutateAsync, + isPending: mutation.isPending + } +} diff --git a/web/src/hooks/queries/useSessionSortPreference.test.tsx b/web/src/hooks/queries/useSessionSortPreference.test.tsx new file mode 100644 index 000000000..29741446e --- /dev/null +++ b/web/src/hooks/queries/useSessionSortPreference.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' + +import { useSessionSortPreference } from './useSessionSortPreference' + +function createWrapper(queryClient: QueryClient) { + return function Wrapper(props: { children: ReactNode }) { + return ( + + {props.children} + + ) + } +} + +describe('useSessionSortPreference', () => { + it('loads preference from API', async () => { + const queryClient = new QueryClient() + const api = { + getSessionSortPreference: vi.fn(async () => ({ + preference: { + sortMode: 'manual', + manualOrder: { + groupOrder: ['group-a'], + sessionOrder: { + 'group-a': ['session-1'] + } + }, + version: 3, + updatedAt: 123 + } + })) + } + + const { result } = renderHook(() => useSessionSortPreference(api as never), { + wrapper: createWrapper(queryClient) + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.preference.sortMode).toBe('manual') + expect(result.current.preference.version).toBe(3) + expect(api.getSessionSortPreference).toHaveBeenCalledTimes(1) + }) + + it('returns defaults when API not available', () => { + const queryClient = new QueryClient() + + const { result } = renderHook(() => useSessionSortPreference(null), { + wrapper: createWrapper(queryClient) + }) + + expect(result.current.preference.sortMode).toBe('auto') + expect(result.current.preference.manualOrder.groupOrder).toEqual([]) + }) +}) diff --git a/web/src/hooks/queries/useSessionSortPreference.ts b/web/src/hooks/queries/useSessionSortPreference.ts new file mode 100644 index 000000000..2b6cbf50a --- /dev/null +++ b/web/src/hooks/queries/useSessionSortPreference.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query' + +import type { ApiClient } from '@/api/client' +import type { SessionSortPreference } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +const DEFAULT_SORT_PREFERENCE: SessionSortPreference = { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + version: 1, + updatedAt: 0 +} + +export function useSessionSortPreference(api: ApiClient | null): { + preference: SessionSortPreference + isLoading: boolean + error: string | null + refetch: () => Promise +} { + const query = useQuery({ + queryKey: queryKeys.sessionSortPreference, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + + return await api.getSessionSortPreference() + }, + enabled: Boolean(api) + }) + + return { + preference: query.data?.preference ?? DEFAULT_SORT_PREFERENCE, + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load session sort preference' : null, + refetch: query.refetch + } +} diff --git a/web/src/hooks/useLongPress.test.ts b/web/src/hooks/useLongPress.test.ts new file mode 100644 index 000000000..1c0a3146f --- /dev/null +++ b/web/src/hooks/useLongPress.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import type React from 'react' +import { useLongPress } from './useLongPress' + +function pointerEvent(init: Partial<{ + isPrimary: boolean + pointerType: 'mouse' | 'touch' | 'pen' + button: number + clientX: number + clientY: number +}> = {}): React.PointerEvent { + return { + isPrimary: init.isPrimary ?? true, + pointerType: init.pointerType ?? 'touch', + button: init.button ?? 0, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0 + } as unknown as React.PointerEvent +} + +describe('useLongPress', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('fires onLongPress after the threshold elapses', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).toHaveBeenCalledWith({ x: 10, y: 20 }) + expect(onClick).not.toHaveBeenCalled() + }) + + it('fires onClick on pointer up before the threshold', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent()) + result.current.onPointerUp(pointerEvent()) + }) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onLongPress).not.toHaveBeenCalled() + }) + + it('cancels the timer and suppresses click when movement exceeds the threshold', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent({ clientX: 0, clientY: 0 })) + result.current.onPointerMove(pointerEvent({ clientX: 20, clientY: 0 })) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + act(() => { + result.current.onPointerUp(pointerEvent({ clientX: 20, clientY: 0 })) + }) + + expect(onLongPress).not.toHaveBeenCalled() + expect(onClick).not.toHaveBeenCalled() + }) + + it('ignores sub-threshold movement', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent({ clientX: 0, clientY: 0 })) + result.current.onPointerMove(pointerEvent({ clientX: 3, clientY: 2 })) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).toHaveBeenCalledTimes(1) + }) + + it('ignores non-primary pointers and non-left mouse buttons', () => { + const onLongPress = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent({ isPrimary: false })) + result.current.onPointerDown(pointerEvent({ pointerType: 'mouse', button: 2 })) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).not.toHaveBeenCalled() + }) + + it('does not start the long-press timer when disabled', () => { + const onLongPress = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, threshold: 500, disabled: true }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent()) + }) + act(() => { + vi.advanceTimersByTime(1_000) + }) + + expect(onLongPress).not.toHaveBeenCalled() + }) + + it('ignores keyboard activation when disabled', () => { + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress: vi.fn(), onClick, disabled: true }) + ) + + const preventDefault = vi.fn() + const enter = { key: 'Enter', preventDefault } as unknown as React.KeyboardEvent + + act(() => { + result.current.onKeyDown(enter) + }) + + expect(onClick).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('cancels on pointer cancel and pointer leave', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent()) + result.current.onPointerCancel(pointerEvent()) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).not.toHaveBeenCalled() + expect(onClick).not.toHaveBeenCalled() + + act(() => { + result.current.onPointerDown(pointerEvent()) + result.current.onPointerLeave(pointerEvent()) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).not.toHaveBeenCalled() + expect(onClick).not.toHaveBeenCalled() + }) + + it('triggers onClick on Enter and Space keys', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick }) + ) + + const preventDefault = vi.fn() + const enter = { key: 'Enter', preventDefault } as unknown as React.KeyboardEvent + const space = { key: ' ', preventDefault } as unknown as React.KeyboardEvent + + act(() => { + result.current.onKeyDown(enter) + result.current.onKeyDown(space) + }) + + expect(onClick).toHaveBeenCalledTimes(2) + expect(preventDefault).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/src/hooks/useLongPress.ts b/web/src/hooks/useLongPress.ts index 5673b2d7d..ec65f7882 100644 --- a/web/src/hooks/useLongPress.ts +++ b/web/src/hooks/useLongPress.ts @@ -9,12 +9,11 @@ type UseLongPressOptions = { } type UseLongPressHandlers = { - onMouseDown: React.MouseEventHandler - onMouseUp: React.MouseEventHandler - onMouseLeave: React.MouseEventHandler - onTouchStart: React.TouchEventHandler - onTouchEnd: React.TouchEventHandler - onTouchMove: React.TouchEventHandler + onPointerDown: React.PointerEventHandler + onPointerMove: React.PointerEventHandler + onPointerUp: React.PointerEventHandler + onPointerLeave: React.PointerEventHandler + onPointerCancel: React.PointerEventHandler onContextMenu: React.MouseEventHandler onKeyDown: React.KeyboardEventHandler } @@ -24,8 +23,9 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers const timerRef = useRef | null>(null) const isLongPressRef = useRef(false) - const touchMoved = useRef(false) const pressPointRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + const movedBeyondThresholdRef = useRef(false) + const moveThresholdPx = 8 const clearTimer = useCallback(() => { if (timerRef.current) { @@ -39,7 +39,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers clearTimer() isLongPressRef.current = false - touchMoved.current = false + movedBeyondThresholdRef.current = false pressPointRef.current = { x: clientX, y: clientY } timerRef.current = setTimeout(() => { @@ -51,44 +51,48 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers const handleEnd = useCallback((shouldTriggerClick: boolean) => { clearTimer() - if (shouldTriggerClick && !isLongPressRef.current && !touchMoved.current && onClick) { + if (shouldTriggerClick && !isLongPressRef.current && onClick) { onClick() } isLongPressRef.current = false - touchMoved.current = false + movedBeyondThresholdRef.current = false }, [clearTimer, onClick]) - const onMouseDown = useCallback((e) => { - if (e.button !== 0) return + const onPointerDown = useCallback((e) => { + if (!e.isPrimary) return + if (e.pointerType === 'mouse' && e.button !== 0) return startTimer(e.clientX, e.clientY) }, [startTimer]) - const onMouseUp = useCallback(() => { - handleEnd(!isLongPressRef.current) + const onPointerMove = useCallback((e) => { + if (!e.isPrimary || movedBeyondThresholdRef.current) return + + const dx = e.clientX - pressPointRef.current.x + const dy = e.clientY - pressPointRef.current.y + if (Math.hypot(dx, dy) < moveThresholdPx) { + return + } + + movedBeyondThresholdRef.current = true + clearTimer() + }, [clearTimer]) + + const onPointerUp = useCallback((e) => { + if (!e.isPrimary) return + handleEnd(!isLongPressRef.current && !movedBeyondThresholdRef.current) }, [handleEnd]) - const onMouseLeave = useCallback(() => { + const onPointerLeave = useCallback((e) => { + if (!e.isPrimary) return handleEnd(false) }, [handleEnd]) - const onTouchStart = useCallback((e) => { - const touch = e.touches[0] - startTimer(touch.clientX, touch.clientY) - }, [startTimer]) - - const onTouchEnd = useCallback((e) => { - if (isLongPressRef.current) { - e.preventDefault() - } - handleEnd(!isLongPressRef.current) + const onPointerCancel = useCallback((e) => { + if (!e.isPrimary) return + handleEnd(false) }, [handleEnd]) - const onTouchMove = useCallback(() => { - touchMoved.current = true - clearTimer() - }, [clearTimer]) - const onContextMenu = useCallback((e) => { if (!disabled) { e.preventDefault() @@ -107,12 +111,11 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers }, [disabled, onClick]) return { - onMouseDown, - onMouseUp, - onMouseLeave, - onTouchStart, - onTouchEnd, - onTouchMove, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerLeave, + onPointerCancel, onContextMenu, onKeyDown } diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 66f310d2d..b2bdb4541 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -538,6 +538,10 @@ export function useSSE(options: { } } + if (event.type === 'session-sort-preference-updated') { + void queryClient.invalidateQueries({ queryKey: queryKeys.sessionSortPreference }) + } + onEventRef.current(event) } diff --git a/web/src/hooks/useSortToggle.ts b/web/src/hooks/useSortToggle.ts new file mode 100644 index 000000000..821db3bd8 --- /dev/null +++ b/web/src/hooks/useSortToggle.ts @@ -0,0 +1,80 @@ +import { useMemo } from 'react' + +import type { ApiClient } from '@/api/client' +import type { SessionManualOrder } from '@/types/api' +import { useSessionSortPreference } from '@/hooks/queries/useSessionSortPreference' +import { useSessionSortPreferenceMutation } from '@/hooks/mutations/useSessionSortPreference' +import { + reconcileManualOrder, + snapshotManualOrder, + applyManualOrder, + moveGroup, + moveSession, +} from '@/lib/sessionSortOrder' + +type SortableSession = { id: string } +type SortableGroup = { + key: string + sessions: TSession[] +} + +export function useSortToggle< + TSession extends SortableSession, + TGroup extends SortableGroup +>(api: ApiClient | null, groups: TGroup[]) { + const { preference } = useSessionSortPreference(api) + const { setSessionSortPreference, isPending: isSortPreferencePending } = useSessionSortPreferenceMutation(api) + const sortMode = preference.sortMode + + const reconciledManualOrder = useMemo( + () => reconcileManualOrder(groups, preference.manualOrder), + [groups, preference.manualOrder] + ) + + const orderedGroups = useMemo( + () => (sortMode === 'manual' ? applyManualOrder(groups, reconciledManualOrder) : groups), + [groups, reconciledManualOrder, sortMode] + ) + + const persistSortPreference = (nextSortMode: 'auto' | 'manual', nextManualOrder: SessionManualOrder = reconciledManualOrder) => { + if (!api) return + void setSessionSortPreference({ + sortMode: nextSortMode, + manualOrder: nextManualOrder, + expectedVersion: preference.version + }).catch((error) => { + console.error('Failed to persist session sort preference:', error) + }) + } + + const toggleSortMode = () => { + if (sortMode === 'auto') { + persistSortPreference('manual', snapshotManualOrder(groups)) + return + } + persistSortPreference('auto', reconciledManualOrder) + } + + const moveGroupInPreference = (groupKey: string, direction: 'up' | 'down') => { + if (sortMode !== 'manual') return + const nextManualOrder = moveGroup(reconciledManualOrder, groupKey, direction) + if (nextManualOrder === reconciledManualOrder) return + persistSortPreference('manual', nextManualOrder) + } + + const moveSessionInPreference = (groupKey: string, sessionId: string, direction: 'up' | 'down') => { + if (sortMode !== 'manual') return + const nextManualOrder = moveSession(reconciledManualOrder, groupKey, sessionId, direction) + if (nextManualOrder === reconciledManualOrder) return + persistSortPreference('manual', nextManualOrder) + } + + return { + sortMode, + orderedGroups, + isSortPreferencePending, + toggleSortMode, + moveGroupInPreference, + moveSessionInPreference, + } +} diff --git a/web/src/hooks/useTheme.test.ts b/web/src/hooks/useTheme.test.ts new file mode 100644 index 000000000..b4025491d --- /dev/null +++ b/web/src/hooks/useTheme.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { initializeTheme, useTheme } from './useTheme' + +vi.mock('./useTelegram', () => ({ + getTelegramWebApp: vi.fn(() => null) +})) + +function createMatchMediaStub(initialDark = false) { + const listeners = new Set() + const mediaQuery = { + media: '(prefers-color-scheme: dark)', + matches: initialDark, + onchange: null, + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + listeners.add(listener) + }, + removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + listeners.delete(listener) + }, + dispatchEvent: () => true, + addListener: () => {}, + removeListener: () => {}, + } as MediaQueryList + + const setDark = (next: boolean) => { + ;(mediaQuery as { matches: boolean }).matches = next + const event = { matches: next } as MediaQueryListEvent + listeners.forEach((listener) => { + if (typeof listener === 'function') { + listener.call(mediaQuery, event) + return + } + listener.handleEvent(event) + }) + } + + return { mediaQuery, setDark } +} + +describe('useTheme', () => { + beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + const { mediaQuery } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + }) + + it('initializeTheme applies stored theme preference', () => { + const { mediaQuery } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + localStorage.setItem('hapi-theme', 'catpuccin') + + initializeTheme() + + expect(document.documentElement.getAttribute('data-theme')).toBe('catpuccin') + }) + + it('initializeTheme resolves gaius against system scheme', () => { + const { mediaQuery } = createMatchMediaStub(true) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + localStorage.setItem('hapi-theme', 'gaius') + + initializeTheme() + + expect(document.documentElement.getAttribute('data-theme')).toBe('gaius-dark') + }) + + it('initializeTheme keeps system theme reactive without mounted hook', () => { + const { mediaQuery, setDark } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + localStorage.removeItem('hapi-theme') + + initializeTheme() + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + + act(() => setDark(true)) + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + }) + + it('persists and clears theme preference from hook setter', () => { + const { mediaQuery } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + + const { result } = renderHook(() => useTheme()) + act(() => result.current.setThemePreference('dark')) + expect(localStorage.getItem('hapi-theme')).toBe('dark') + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + + act(() => result.current.setThemePreference('system')) + expect(localStorage.getItem('hapi-theme')).toBeNull() + }) + + it('updates resolved gaius theme when system preference changes', () => { + const { mediaQuery, setDark } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + localStorage.setItem('hapi-theme', 'gaius') + + initializeTheme() + renderHook(() => useTheme()) + expect(document.documentElement.getAttribute('data-theme')).toBe('gaius-light') + + act(() => setDark(true)) + expect(document.documentElement.getAttribute('data-theme')).toBe('gaius-dark') + }) + + it('shares theme preference state across hook consumers', () => { + const { mediaQuery } = createMatchMediaStub(false) + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn(() => mediaQuery) + }) + initializeTheme() + + const first = renderHook(() => useTheme()) + const second = renderHook(() => useTheme()) + + act(() => first.result.current.setThemePreference('dark')) + expect(second.result.current.themePreference).toBe('dark') + }) +}) diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index 6d177fcbb..dabf68f33 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -1,16 +1,20 @@ -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore } from 'react' import { getTelegramWebApp } from './useTelegram' -type ColorScheme = 'light' | 'dark' +export type ThemePreference = 'system' | 'light' | 'dark' | 'catpuccin' | 'gaius' | 'gaius-light' | 'gaius-dark' +type ResolvedTheme = 'light' | 'dark' | 'catpuccin' | 'gaius-light' | 'gaius-dark' export type AppearancePreference = 'system' | 'dark' | 'light' +const STORAGE_KEY = 'hapi-theme' const APPEARANCE_KEY = 'hapi-appearance' function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined' } +const useIsomorphicLayoutEffect = isBrowser() ? useLayoutEffect : useEffect + function safeGetItem(key: string): string | null { if (!isBrowser()) return null try { @@ -38,6 +42,11 @@ function safeRemoveItem(key: string): void { } } +function parseThemePreference(raw: string | null): ThemePreference { + if (raw === 'light' || raw === 'dark' || raw === 'catpuccin' || raw === 'gaius' || raw === 'gaius-light' || raw === 'gaius-dark') return raw + return 'system' +} + function parseAppearance(raw: string | null): AppearancePreference { if (raw === 'dark' || raw === 'light') return raw return 'system' @@ -55,30 +64,31 @@ export function getAppearanceOptions(): ReadonlyArray<{ value: AppearancePrefere ] } -function getColorScheme(): ColorScheme { - const pref = getStoredAppearance() - if (pref === 'dark' || pref === 'light') return pref - - // 'system': use Telegram → system preference → light +function getSystemColorScheme(): 'light' | 'dark' { const tg = getTelegramWebApp() if (tg?.colorScheme) { return tg.colorScheme === 'dark' ? 'dark' : 'light' } - - // Fallback to system preference for browser environment - if (typeof window !== 'undefined' && window.matchMedia) { + if (isBrowser() && window.matchMedia) { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } - return 'light' } -function isIOS(): boolean { - return /iPad|iPhone|iPod/.test(navigator.userAgent) +function resolveTheme(pref: ThemePreference): ResolvedTheme { + if (pref === 'system') return getSystemColorScheme() + if (pref === 'gaius') return getSystemColorScheme() === 'dark' ? 'gaius-dark' : 'gaius-light' + return pref +} + +function applyTheme(theme: ResolvedTheme): void { + if (!isBrowser()) return + document.documentElement.setAttribute('data-theme', theme) } -function applyTheme(scheme: ColorScheme): void { - document.documentElement.setAttribute('data-theme', scheme) +function isIOS(): boolean { + if (typeof navigator === 'undefined') return false + return /iPad|iPhone|iPod/.test(navigator.userAgent) } function applyPlatform(): void { @@ -87,40 +97,142 @@ function applyPlatform(): void { } } -// External store for theme state -let currentScheme: ColorScheme = getColorScheme() -const listeners = new Set<() => void>() +function getInitialPreference(): ThemePreference { + return parseThemePreference(safeGetItem(STORAGE_KEY)) +} -// Apply theme immediately at module load (before React renders) -applyTheme(currentScheme) +function isSystemLinkedPreference(pref: ThemePreference): boolean { + return pref === 'system' || pref === 'gaius' +} -function subscribe(callback: () => void): () => void { - listeners.add(callback) - return () => listeners.delete(callback) +let themePreferenceState: ThemePreference = getInitialPreference() +const themeSubscribers = new Set<() => void>() +let storageSyncInitialized = false +let mediaQueryListenerInitialized = false +let telegramThemeListenerInitialized = false +let mediaQueryList: MediaQueryList | null = null + +function notifyThemeSubscribers(): void { + themeSubscribers.forEach((subscriber) => subscriber()) +} + +function applyThemePreference(pref: ThemePreference): void { + themePreferenceState = pref + applyTheme(resolveTheme(pref)) +} + +function persistThemePreference(pref: ThemePreference): void { + if (pref === 'system') { + safeRemoveItem(STORAGE_KEY) + return + } + safeSetItem(STORAGE_KEY, pref) } -function getSnapshot(): ColorScheme { - return currentScheme +function setThemePreferenceState(pref: ThemePreference, options?: { persist?: boolean }): void { + const previous = themePreferenceState + applyThemePreference(pref) + if (options?.persist !== false) { + persistThemePreference(pref) + } + if (previous !== pref) { + notifyThemeSubscribers() + } } -function updateScheme(): void { - const newScheme = getColorScheme() - if (newScheme !== currentScheme) { - currentScheme = newScheme - applyTheme(newScheme) - listeners.forEach((cb) => cb()) +function onSystemThemeChanged(): void { + if (!isSystemLinkedPreference(themePreferenceState)) { + return + } + applyTheme(resolveTheme(themePreferenceState)) + notifyThemeSubscribers() +} + +function ensureThemeListeners(): void { + if (!isBrowser()) return + + if (!storageSyncInitialized) { + const onStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY) return + const next = parseThemePreference(event.newValue) + setThemePreferenceState(next, { persist: false }) + } + window.addEventListener('storage', onStorage) + storageSyncInitialized = true + } + + if (window.matchMedia) { + const nextMediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + if (!mediaQueryListenerInitialized || mediaQueryList !== nextMediaQuery) { + mediaQueryList?.removeEventListener('change', onSystemThemeChanged) + nextMediaQuery.addEventListener('change', onSystemThemeChanged) + mediaQueryList = nextMediaQuery + mediaQueryListenerInitialized = true + } + } + + if (!telegramThemeListenerInitialized) { + const tg = getTelegramWebApp() + if (tg?.onEvent) { + tg.onEvent('themeChanged', onSystemThemeChanged) + telegramThemeListenerInitialized = true + } } } -// Track if theme listeners have been set up -let listenersInitialized = false +export function initializeTheme(): void { + themePreferenceState = getInitialPreference() + applyPlatform() + applyTheme(resolveTheme(themePreferenceState)) + ensureThemeListeners() +} + +export function getThemeOptions(): ReadonlyArray<{ value: ThemePreference; label: string }> { + return [ + { value: 'system', label: 'system' }, + { value: 'light', label: 'light' }, + { value: 'dark', label: 'dark' }, + { value: 'catpuccin', label: 'catpuccin' }, + { value: 'gaius', label: 'gaius' }, + { value: 'gaius-light', label: 'gaius-light' }, + { value: 'gaius-dark', label: 'gaius-dark' }, + ] +} -export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { - const colorScheme = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +export function useTheme(): { + themePreference: ThemePreference + setThemePreference: (pref: ThemePreference) => void + isDark: boolean +} { + const themePreference = useSyncExternalStore( + (subscriber) => { + ensureThemeListeners() + themeSubscribers.add(subscriber) + return () => { + themeSubscribers.delete(subscriber) + } + }, + () => themePreferenceState, + () => themePreferenceState + ) + const resolved = resolveTheme(themePreference) + + useIsomorphicLayoutEffect(() => { + applyTheme(resolved) + }, [resolved]) + + useEffect(() => { + ensureThemeListeners() + }, []) + + const setThemePreference = useCallback((pref: ThemePreference) => { + setThemePreferenceState(pref) + }, []) return { - colorScheme, - isDark: colorScheme === 'dark', + themePreference, + setThemePreference, + isDark: resolved !== 'light' && resolved !== 'gaius-light', } } @@ -147,36 +259,7 @@ export function useAppearance(): { appearance: AppearancePreference; setAppearan } else { safeSetItem(APPEARANCE_KEY, pref) } - - updateScheme() }, []) return { appearance, setAppearance } } - -// Call this once at app startup to ensure theme is applied and listeners attached -export function initializeTheme(): void { - currentScheme = getColorScheme() - applyTheme(currentScheme) - - // Set up listeners only once (after SDK may have loaded) - if (!listenersInitialized) { - listenersInitialized = true - const tg = getTelegramWebApp() - if (tg?.onEvent) { - // Telegram theme changes - tg.onEvent('themeChanged', updateScheme) - } else if (typeof window !== 'undefined' && window.matchMedia) { - // Browser system preference changes - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - mediaQuery.addEventListener('change', updateScheme) - } - - // Cross-tab appearance sync: update theme when another tab changes localStorage - if (typeof window !== 'undefined') { - window.addEventListener('storage', (event: StorageEvent) => { - if (event.key === APPEARANCE_KEY) updateScheme() - }) - } - } -} diff --git a/web/src/index.css b/web/src/index.css index 41bc44877..5bcc9bd2f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -33,6 +33,9 @@ --app-git-untracked-color: #8E8E93; /* Badge colors (light) */ + --app-badge-info-bg: rgba(59, 130, 246, 0.12); + --app-badge-info-text: #1d4ed8; + --app-badge-info-border: rgba(59, 130, 246, 0.22); --app-badge-warning-bg: rgba(245, 158, 11, 0.2); --app-badge-warning-text: #b45309; --app-badge-warning-border: rgba(245, 158, 11, 0.3); @@ -43,6 +46,26 @@ --app-badge-error-text: #b91c1c; --app-badge-error-border: rgba(239, 68, 68, 0.3); + --app-flavor-claude: #b45309; + --app-flavor-claude-bg: rgba(245, 158, 11, 0.12); + --app-flavor-claude-text: #b45309; + --app-flavor-claude-border: rgba(245, 158, 11, 0.24); + --app-flavor-codex: #1d4ed8; + --app-flavor-codex-bg: rgba(59, 130, 246, 0.12); + --app-flavor-codex-text: #1d4ed8; + --app-flavor-codex-border: rgba(59, 130, 246, 0.24); + --app-flavor-gemini: #6d28d9; + --app-flavor-gemini-bg: rgba(139, 92, 246, 0.12); + --app-flavor-gemini-text: #6d28d9; + --app-flavor-gemini-border: rgba(139, 92, 246, 0.24); + --app-flavor-opencode: #047857; + --app-flavor-opencode-bg: rgba(16, 185, 129, 0.12); + --app-flavor-opencode-text: #047857; + --app-flavor-opencode-border: rgba(16, 185, 129, 0.24); + --app-flavor-cursor-bg: rgba(100, 116, 139, 0.12); + --app-flavor-cursor-text: #475569; + --app-flavor-cursor-border: rgba(100, 116, 139, 0.24); + --app-font-scale: 1; } @@ -78,6 +101,9 @@ --app-git-untracked-color: #9ca3af; /* Badge colors (dark) */ + --app-badge-info-bg: rgba(96, 165, 250, 0.18); + --app-badge-info-text: #93c5fd; + --app-badge-info-border: rgba(96, 165, 250, 0.28); --app-badge-warning-bg: rgba(251, 191, 36, 0.2); --app-badge-warning-text: #fbbf24; --app-badge-warning-border: rgba(251, 191, 36, 0.3); @@ -87,6 +113,26 @@ --app-badge-error-bg: rgba(248, 113, 113, 0.2); --app-badge-error-text: #fca5a5; --app-badge-error-border: rgba(248, 113, 113, 0.35); + + --app-flavor-claude: #fcd34d; + --app-flavor-claude-bg: rgba(251, 191, 36, 0.16); + --app-flavor-claude-text: #fcd34d; + --app-flavor-claude-border: rgba(251, 191, 36, 0.28); + --app-flavor-codex: #93c5fd; + --app-flavor-codex-bg: rgba(96, 165, 250, 0.16); + --app-flavor-codex-text: #93c5fd; + --app-flavor-codex-border: rgba(96, 165, 250, 0.28); + --app-flavor-gemini: #c4b5fd; + --app-flavor-gemini-bg: rgba(167, 139, 250, 0.16); + --app-flavor-gemini-text: #c4b5fd; + --app-flavor-gemini-border: rgba(167, 139, 250, 0.28); + --app-flavor-opencode: #6ee7b7; + --app-flavor-opencode-bg: rgba(52, 211, 153, 0.16); + --app-flavor-opencode-text: #6ee7b7; + --app-flavor-opencode-border: rgba(52, 211, 153, 0.28); + --app-flavor-cursor-bg: rgba(148, 163, 184, 0.16); + --app-flavor-cursor-text: #cbd5e1; + --app-flavor-cursor-border: rgba(148, 163, 184, 0.28); } html { @@ -98,22 +144,12 @@ body { height: 100vh; height: 100dvh; height: var(--tg-viewport-stable-height, 100dvh); + overflow: hidden; touch-action: pan-x pan-y; overscroll-behavior: none; -webkit-text-size-adjust: 100%; } -html[data-telegram-app="true"], -html[data-telegram-app="true"] body { - overflow: hidden; -} - -html:not([data-telegram-app="true"]), -html:not([data-telegram-app="true"]) body { - overflow-x: hidden; - overflow-y: auto; -} - body { font-size: 1rem; background: var(--app-bg); @@ -292,6 +328,30 @@ html[data-theme="dark"] .shiki span { animation: bounce-in 0.3s ease-out; } +/* Session list item enter animation */ +@keyframes session-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-session-enter { + animation: session-enter 0.2s ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .animate-session-enter { + animation: none; + opacity: 1; + transform: none; + } +} + /* ReactQueryDevtools button - move to middle-right to avoid blocking UI */ .tsqd-open-btn-container { bottom: 50% !important; diff --git a/web/src/lib/agentFlavorUtils.test.ts b/web/src/lib/agentFlavorUtils.test.ts new file mode 100644 index 000000000..cdc6cbbc9 --- /dev/null +++ b/web/src/lib/agentFlavorUtils.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + formatEffortLabel, + getFlavorTextClass, + META_DOT_SEPARATOR_CLASS, + supportsModelChange +} from './agentFlavorUtils' + +describe('getFlavorTextClass', () => { + it.each([ + ['claude', 'text-[var(--app-flavor-claude-text)] font-medium'], + ['codex', 'text-[var(--app-flavor-codex-text)] font-medium'], + ['gemini', 'text-[var(--app-flavor-gemini-text)] font-medium'], + ['opencode', 'text-[var(--app-flavor-opencode-text)] font-medium'], + ['cursor', 'text-[var(--app-flavor-cursor-text)] font-medium'] + ])('returns flavor class for %s', (flavor, expected) => { + expect(getFlavorTextClass(flavor)).toBe(expected) + }) + + it('falls back for unknown flavors', () => { + expect(getFlavorTextClass('mystery')).toBe('text-[var(--app-hint)] font-medium') + }) + + it('falls back for nullish values', () => { + expect(getFlavorTextClass(null)).toBe('text-[var(--app-hint)] font-medium') + expect(getFlavorTextClass(undefined)).toBe('text-[var(--app-hint)] font-medium') + }) + + it('normalizes whitespace and casing', () => { + expect(getFlavorTextClass(' CoDeX ')).toBe('text-[var(--app-flavor-codex-text)] font-medium') + }) +}) + +describe('formatEffortLabel', () => { + it('returns null for nullish and blank values', () => { + expect(formatEffortLabel(null)).toBeNull() + expect(formatEffortLabel('')).toBeNull() + expect(formatEffortLabel(' ')).toBeNull() + }) + + it('title-cases segmented effort labels', () => { + expect(formatEffortLabel('very-high')).toBe('Very High') + expect(formatEffortLabel('max_reasoning effort')).toBe('Max Reasoning Effort') + }) +}) + +describe('META_DOT_SEPARATOR_CLASS', () => { + it('exports the expected separator class', () => { + expect(META_DOT_SEPARATOR_CLASS).toBe('text-[var(--app-hint)] opacity-40') + }) +}) + +describe('supportsModelChange', () => { + it.each(['claude', 'gemini'])('returns true for %s', (flavor) => { + expect(supportsModelChange(flavor)).toBe(true) + }) + + it.each(['codex', 'cursor', 'opencode', 'mystery'])('returns false for %s', (flavor) => { + expect(supportsModelChange(flavor)).toBe(false) + }) + + it('returns false for nullish values', () => { + expect(supportsModelChange(null)).toBe(false) + expect(supportsModelChange(undefined)).toBe(false) + expect(supportsModelChange('')).toBe(false) + }) + + it('normalizes whitespace and casing', () => { + expect(supportsModelChange(' Claude ')).toBe(true) + expect(supportsModelChange('GEMINI')).toBe(true) + }) +}) diff --git a/web/src/lib/agentFlavorUtils.ts b/web/src/lib/agentFlavorUtils.ts index d835b3505..52cfa640c 100644 --- a/web/src/lib/agentFlavorUtils.ts +++ b/web/src/lib/agentFlavorUtils.ts @@ -1,19 +1,57 @@ +function normalizeFlavor(flavor?: string | null): string | null { + const normalized = flavor?.trim().toLowerCase() + return normalized || null +} + +const SESSION_META_BADGE_BASE = 'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-medium leading-none' + +export const SESSION_ACTIVITY_BADGE = `${SESSION_META_BADGE_BASE} border-[var(--app-badge-info-border)] bg-[var(--app-badge-info-bg)] text-[var(--app-badge-info-text)]` +export const SESSION_PENDING_BADGE = `${SESSION_META_BADGE_BASE} border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)] text-[var(--app-badge-warning-text)]` + export function isCodexFamilyFlavor(flavor?: string | null): boolean { - return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' + const normalized = normalizeFlavor(flavor) + return normalized === 'codex' || normalized === 'gemini' || normalized === 'opencode' } export function isClaudeFlavor(flavor?: string | null): boolean { - return flavor === 'claude' + return normalizeFlavor(flavor) === 'claude' } export function isCursorFlavor(flavor?: string | null): boolean { - return flavor === 'cursor' + return normalizeFlavor(flavor) === 'cursor' } export function isKnownFlavor(flavor?: string | null): boolean { return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) || isCursorFlavor(flavor) } +const FLAVOR_TEXT_CLASSES: Record = { + claude: 'text-[var(--app-flavor-claude-text)] font-medium', + codex: 'text-[var(--app-flavor-codex-text)] font-medium', + gemini: 'text-[var(--app-flavor-gemini-text)] font-medium', + opencode: 'text-[var(--app-flavor-opencode-text)] font-medium', + cursor: 'text-[var(--app-flavor-cursor-text)] font-medium' +} + +export function getFlavorTextClass(flavor?: string | null): string { + const normalized = normalizeFlavor(flavor) + return normalized ? (FLAVOR_TEXT_CLASSES[normalized] ?? 'text-[var(--app-hint)] font-medium') : 'text-[var(--app-hint)] font-medium' +} + +export const META_DOT_SEPARATOR_CLASS = 'text-[var(--app-hint)] opacity-40' + +export function formatEffortLabel(effort?: string | null): string | null { + const normalized = effort?.trim() + if (!normalized) return null + + return normalized + .split(/[-_\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + export function supportsModelChange(flavor?: string | null): boolean { - return flavor === 'claude' || flavor === 'gemini' + const normalized = normalizeFlavor(flavor) + return normalized === 'claude' || normalized === 'gemini' } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index f26126109..76f89eab9 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -41,6 +41,8 @@ export default { // Sessions page 'sessions.count': '{n} sessions in {m} projects', 'sessions.new': 'New Session', + 'sessions.sort.auto': 'Sort: automatic', + 'sessions.sort.manual': 'Sort: manual', // Session list 'session.item.path': 'path', @@ -63,6 +65,11 @@ export default { 'session.action.archive': 'Archive', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', + 'session.action.moveUp': 'Move Up', + 'session.action.moveDown': 'Move Down', + 'group.more': 'Group actions', + 'group.action.moveUp': 'Move Up', + 'group.action.moveDown': 'Move Down', // Dialogs 'dialog.rename.title': 'Rename Session', @@ -257,6 +264,14 @@ export default { 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', + 'settings.display.theme': 'Theme', + 'settings.display.theme.system': 'System', + 'settings.display.theme.light': 'Light', + 'settings.display.theme.dark': 'Dark', + 'settings.display.theme.catpuccin': 'Catppuccin', + 'settings.display.theme.gaius': 'Gaius', + 'settings.display.theme.gaius-light': 'Gaius Light', + 'settings.display.theme.gaius-dark': 'Gaius Dark', 'settings.display.appearance': 'Appearance', 'settings.display.appearance.system': 'Follow System', 'settings.display.appearance.dark': 'Dark', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ea220f5a7..7f26d6ac7 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -41,6 +41,8 @@ export default { // Sessions page 'sessions.count': '{n} 个会话,{m} 个项目', 'sessions.new': '新建会话', + 'sessions.sort.auto': '排序:自动', + 'sessions.sort.manual': '排序:手动', // Session list 'session.item.path': '路径', @@ -63,6 +65,11 @@ export default { 'session.action.archive': '归档', 'session.action.delete': '删除', 'session.action.copy': '复制', + 'session.action.moveUp': '上移', + 'session.action.moveDown': '下移', + 'group.more': '分组操作', + 'group.action.moveUp': '上移', + 'group.action.moveDown': '下移', // Dialogs 'dialog.rename.title': '重命名会话', @@ -259,6 +266,14 @@ export default { 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', + 'settings.display.theme': '主题', + 'settings.display.theme.system': '跟随系统', + 'settings.display.theme.light': '浅色', + 'settings.display.theme.dark': '深色', + 'settings.display.theme.catpuccin': 'Catppuccin', + 'settings.display.theme.gaius': 'Gaius', + 'settings.display.theme.gaius-light': 'Gaius 浅色', + 'settings.display.theme.gaius-dark': 'Gaius 深色', 'settings.display.appearance': '外观', 'settings.display.appearance.system': '跟随系统', 'settings.display.appearance.dark': '深色', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a00b5512b..7cfe79326 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -15,4 +15,5 @@ export const queryKeys = { ] as const, slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, skills: (sessionId: string) => ['skills', sessionId] as const, + sessionSortPreference: ['session-sort-preference'] as const, } diff --git a/web/src/lib/sessionSortOrder.test.ts b/web/src/lib/sessionSortOrder.test.ts new file mode 100644 index 000000000..55b54aa08 --- /dev/null +++ b/web/src/lib/sessionSortOrder.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' + +import { + applyManualOrder, + moveGroup, + moveSession, + reconcileManualOrder, + snapshotManualOrder +} from './sessionSortOrder' + +type TestSession = { + id: string +} + +type TestGroup = { + key: string + sessions: TestSession[] +} + +function makeGroups(): TestGroup[] { + return [ + { + key: 'group-a', + sessions: [{ id: 'a1' }, { id: 'a2' }] + }, + { + key: 'group-b', + sessions: [{ id: 'b1' }, { id: 'b2' }] + } + ] +} + +describe('sessionSortOrder', () => { + it('captures snapshot for groups and sessions', () => { + const groups = makeGroups() + const snapshot = snapshotManualOrder(groups) + + expect(snapshot.groupOrder).toEqual(['group-a', 'group-b']) + expect(snapshot.sessionOrder['group-a']).toEqual(['a1', 'a2']) + expect(snapshot.sessionOrder['group-b']).toEqual(['b1', 'b2']) + }) + + it('reconciles stale ids and appends unknown items to bottom', () => { + const groups: TestGroup[] = [ + { + key: 'group-b', + sessions: [{ id: 'b2' }, { id: 'b3' }] + }, + { + key: 'group-c', + sessions: [{ id: 'c1' }] + } + ] + + const reconciled = reconcileManualOrder(groups, { + groupOrder: ['group-a', 'group-b'], + sessionOrder: { + 'group-b': ['b1', 'b2'] + } + }) + + expect(reconciled.groupOrder).toEqual(['group-b', 'group-c']) + expect(reconciled.sessionOrder['group-b']).toEqual(['b2', 'b3']) + expect(reconciled.sessionOrder['group-c']).toEqual(['c1']) + }) + + it('applies manual order to groups and sessions', () => { + const groups = makeGroups() + const ordered = applyManualOrder(groups, { + groupOrder: ['group-b', 'group-a'], + sessionOrder: { + 'group-a': ['a2', 'a1'], + 'group-b': ['b2', 'b1'] + } + }) + + expect(ordered.map((group) => group.key)).toEqual(['group-b', 'group-a']) + expect(ordered[0]?.sessions.map((session) => session.id)).toEqual(['b2', 'b1']) + expect(ordered[1]?.sessions.map((session) => session.id)).toEqual(['a2', 'a1']) + }) + + it('moves groups up and down', () => { + const base = { + groupOrder: ['group-a', 'group-b', 'group-c'], + sessionOrder: {} + } + + const movedDown = moveGroup(base, 'group-b', 'down') + expect(movedDown.groupOrder).toEqual(['group-a', 'group-c', 'group-b']) + + const movedUp = moveGroup(base, 'group-b', 'up') + expect(movedUp.groupOrder).toEqual(['group-b', 'group-a', 'group-c']) + + const boundary = moveGroup(base, 'group-a', 'up') + expect(boundary).toBe(base) + }) + + it('moves sessions within the same group', () => { + const base = { + groupOrder: ['group-a'], + sessionOrder: { + 'group-a': ['a1', 'a2', 'a3'] + } + } + + const moved = moveSession(base, 'group-a', 'a2', 'down') + expect(moved.sessionOrder['group-a']).toEqual(['a1', 'a3', 'a2']) + + const boundary = moveSession(base, 'group-a', 'a1', 'up') + expect(boundary).toBe(base) + }) +}) diff --git a/web/src/lib/sessionSortOrder.ts b/web/src/lib/sessionSortOrder.ts new file mode 100644 index 000000000..dee79d45f --- /dev/null +++ b/web/src/lib/sessionSortOrder.ts @@ -0,0 +1,150 @@ +import type { SessionManualOrder } from '@/types/api' + +type Direction = 'up' | 'down' + +type SortableSession = { + id: string +} + +type SortableGroup = { + key: string + sessions: TSession[] +} + +export function snapshotManualOrder>( + groups: TGroup[] +): SessionManualOrder { + const groupOrder = groups.map((group) => group.key) + const sessionOrder: Record = {} + + for (const group of groups) { + sessionOrder[group.key] = group.sessions.map((session) => session.id) + } + + return { + groupOrder, + sessionOrder + } +} + +export function reconcileManualOrder>( + groups: TGroup[], + manualOrder: SessionManualOrder +): SessionManualOrder { + const currentGroupKeys = groups.map((group) => group.key) + const currentGroupKeySet = new Set(currentGroupKeys) + + const knownGroups = manualOrder.groupOrder.filter((groupKey) => currentGroupKeySet.has(groupKey)) + const knownGroupSet = new Set(knownGroups) + const appendedGroups = currentGroupKeys.filter((groupKey) => !knownGroupSet.has(groupKey)) + + const sessionOrder: Record = {} + + for (const group of groups) { + const currentSessionIds = group.sessions.map((session) => session.id) + const currentSessionSet = new Set(currentSessionIds) + const storedSessionOrder = manualOrder.sessionOrder[group.key] ?? [] + const knownSessions = storedSessionOrder.filter((sessionId) => currentSessionSet.has(sessionId)) + const knownSessionSet = new Set(knownSessions) + const appendedSessions = currentSessionIds.filter((sessionId) => !knownSessionSet.has(sessionId)) + + sessionOrder[group.key] = [...knownSessions, ...appendedSessions] + } + + return { + groupOrder: [...knownGroups, ...appendedGroups], + sessionOrder + } +} + +export function applyManualOrder>( + groups: TGroup[], + reconciledOrder: SessionManualOrder +): TGroup[] { + const groupIndex = new Map(reconciledOrder.groupOrder.map((groupKey, index) => [groupKey, index])) + + return [...groups] + .sort((groupA, groupB) => { + const indexA = groupIndex.get(groupA.key) ?? Number.MAX_SAFE_INTEGER + const indexB = groupIndex.get(groupB.key) ?? Number.MAX_SAFE_INTEGER + return indexA - indexB + }) + .map((group) => { + const order = reconciledOrder.sessionOrder[group.key] ?? [] + const sessionIndex = new Map(order.map((sessionId, index) => [sessionId, index])) + const sessions = [...group.sessions].sort((sessionA, sessionB) => { + const indexA = sessionIndex.get(sessionA.id) ?? Number.MAX_SAFE_INTEGER + const indexB = sessionIndex.get(sessionB.id) ?? Number.MAX_SAFE_INTEGER + return indexA - indexB + }) + + return { + ...group, + sessions + } + }) +} + +function swapAdjacent(items: T[], index: number, direction: Direction): T[] { + const targetIndex = direction === 'up' ? index - 1 : index + 1 + if (targetIndex < 0 || targetIndex >= items.length) { + return items + } + + const next = [...items] + const current = next[index] + next[index] = next[targetIndex] as T + next[targetIndex] = current as T + return next +} + +export function moveGroup( + manualOrder: SessionManualOrder, + groupKey: string, + direction: Direction +): SessionManualOrder { + const currentIndex = manualOrder.groupOrder.indexOf(groupKey) + if (currentIndex === -1) { + return manualOrder + } + + const nextGroupOrder = swapAdjacent(manualOrder.groupOrder, currentIndex, direction) + if (nextGroupOrder === manualOrder.groupOrder) { + return manualOrder + } + + return { + groupOrder: nextGroupOrder, + sessionOrder: { ...manualOrder.sessionOrder } + } +} + +export function moveSession( + manualOrder: SessionManualOrder, + groupKey: string, + sessionId: string, + direction: Direction +): SessionManualOrder { + const currentOrder = manualOrder.sessionOrder[groupKey] + if (!currentOrder || currentOrder.length === 0) { + return manualOrder + } + + const currentIndex = currentOrder.indexOf(sessionId) + if (currentIndex === -1) { + return manualOrder + } + + const nextOrder = swapAdjacent(currentOrder, currentIndex, direction) + if (nextOrder === currentOrder) { + return manualOrder + } + + return { + groupOrder: [...manualOrder.groupOrder], + sessionOrder: { + ...manualOrder.sessionOrder, + [groupKey]: nextOrder + } + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 31c1def66..3689e4142 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,6 +6,7 @@ import { RouterProvider, createMemoryHistory } from '@tanstack/react-router' import './index.css' import { registerSW } from 'virtual:pwa-register' import { initializeFontScale } from '@/hooks/useFontScale' +import { initializeTheme } from '@/hooks/useTheme' import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram' import { queryClient } from './lib/query-client' import { createAppRouter } from './router' @@ -35,6 +36,7 @@ function getInitialPath(): string { async function bootstrap() { initializeFontScale() + initializeTheme() // Only load Telegram SDK in Telegram environment (with 3s timeout) const isTelegram = isTelegramEnvironment() diff --git a/web/src/realtime/RealtimeVoiceSession.tsx b/web/src/realtime/RealtimeVoiceSession.tsx index fff9b7b44..f13b428ae 100644 --- a/web/src/realtime/RealtimeVoiceSession.tsx +++ b/web/src/realtime/RealtimeVoiceSession.tsx @@ -16,6 +16,54 @@ let conversationInstance: ReturnType | null = null // Store reference for status updates let statusCallback: StatusCallback | null = null +function getErrorName(error: unknown): string | null { + if (!error || typeof error !== 'object') return null + const maybeName = (error as { name?: unknown }).name + return typeof maybeName === 'string' ? maybeName : null +} + +function resolveVoiceErrorMessage(error: unknown): string { + const name = getErrorName(error) + const rawMessage = error instanceof Error + ? error.message + : (typeof error === 'string' ? error : '') + const message = rawMessage.toLowerCase() + + if ( + name === 'NotAllowedError' + || name === 'PermissionDeniedError' + || message.includes('permission') + || message.includes('not allowed') + || message.includes('not granted') + || message.includes('denied') + ) { + return 'Microphone permission denied' + } + + if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { + return 'No microphone found' + } + + if (name === 'NotReadableError' || name === 'TrackStartError') { + return 'Microphone is busy in another app' + } + + if ( + name === 'SecurityError' + || message.includes('secure context') + || message.includes('https') + || message.includes('insecure') + ) { + return 'Microphone requires HTTPS (or localhost)' + } + + if (rawMessage.trim().length > 0) { + return rawMessage + } + + return 'Failed to start voice session' +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { private api: ApiClient @@ -34,16 +82,15 @@ class RealtimeVoiceSessionImpl implements VoiceSession { statusCallback?.('connecting') - // Request microphone permission first - let permissionStream: MediaStream | null = null - try { - permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true }) - } catch (error) { - console.error('[Voice] Failed to get microphone permission:', error) - statusCallback?.('error', 'Microphone permission denied') + if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) { + const error = new Error( + !window.isSecureContext + ? 'Microphone requires HTTPS (or localhost)' + : 'Microphone is not available in this browser' + ) + console.error('[Voice] Microphone unavailable:', error) + statusCallback?.('error', error.message) throw error - } finally { - permissionStream?.getTracks().forEach((track) => track.stop()) } // Fetch conversation token from server @@ -85,7 +132,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { } } catch (error) { console.error('[Voice] Failed to start realtime session:', error) - statusCallback?.('error', 'Failed to start voice session') + statusCallback?.('error', resolveVoiceErrorMessage(error)) throw error } } @@ -185,7 +232,7 @@ export function RealtimeVoiceSession({ const handleError = useCallback((error: unknown) => { if (DEBUG) console.error('[Voice] Realtime error:', error) - const errorMessage = error instanceof Error ? error.message : 'Connection error' + const errorMessage = resolveVoiceErrorMessage(error) onStatusChange?.('error', errorMessage) }, [onStatusChange]) diff --git a/web/src/router.tsx b/web/src/router.tsx index 7527abcdd..b78f50ef9 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -114,9 +114,11 @@ function SessionsPage() { void refetch() }, [refetch]) - const projectCount = useMemo(() => new Set(sessions.map(s => - s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other' - )).size, [sessions]) + const projectCount = useMemo(() => new Set(sessions.map(s => { + const path = s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other' + const machineId = s.metadata?.machineId ?? '__unknown__' + return `${machineId}::${path}` + })).size, [sessions]) const machineLabelsById = useMemo(() => { const labels: Record = {} for (const machine of machines) { @@ -159,7 +161,7 @@ function SessionsPage() {
-
+
{error ? (
{error}
@@ -285,7 +287,6 @@ function SessionPage() { // Get agent type from session metadata for slash commands const agentType = session?.metadata?.flavor ?? 'claude' const { - commands: slashCommands, getSuggestions: getSlashSuggestions, } = useSlashCommands(api, sessionId, agentType) const { @@ -332,7 +333,6 @@ function SessionPage() { onAtBottomChange={setAtBottom} onRetryMessage={retryMessage} autocompleteSuggestions={getAutocompleteSuggestions} - availableSlashCommands={slashCommands} /> ) } @@ -386,10 +386,7 @@ function NewSessionPage() {
{t('newSession.title')}
-
+
{machinesError ? (
{machinesError} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0a2b01b14..29dc5e53e 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,5 +1,8 @@ import type { DecryptedMessage as ProtocolDecryptedMessage, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, Session, SessionSummary, SyncEvent as ProtocolSyncEvent, @@ -11,6 +14,9 @@ export type { AttachmentMetadata, CodexCollaborationMode, PermissionMode, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, Session, SessionSummary, SessionSummaryMetadata, @@ -212,6 +218,20 @@ export type PushVapidPublicKeyResponse = { publicKey: string } +export type SessionSortPreferenceResponse = { + preference: SessionSortPreference +} + +export type SetSessionSortPreferencePayload = { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + expectedVersion?: number +} + +export type SetSessionSortPreferenceResult = + | { status: 'success'; preference: SessionSortPreference } + | { status: 'version-mismatch'; preference: SessionSortPreference } + export type VisibilityPayload = { subscriptionId: string visibility: 'visible' | 'hidden' diff --git a/web/vite.config.ts b/web/vite.config.ts index 631ac9813..4b6d8472d 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -90,7 +90,8 @@ export default defineConfig({ ] }, injectManifest: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'] + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], + maximumFileSizeToCacheInBytes: 3 * 1024 * 1024 }, devOptions: { enabled: true,