From 5f6547450f266cf1c88c38d2b9c7bb0e06ef522b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 9 May 2026 23:15:09 +0800 Subject: [PATCH] Plan H5 access implementation lanes The implementation needs to land through isolated service, auth, runtime, settings, and mobile-shell tasks so subagents can work without colliding with the desktop default path. Constraint: Follow the approved opt-in H5 design and keep Tauri/local desktop behavior unchanged Constraint: Use subagent-driven execution for implementation work Rejected: One broad implementation pass | too much shared-file risk around AppShell and auth middleware Confidence: high Scope-risk: moderate Directive: Execute Task 4 before Task 5 because both touch AppShell Tested: Plan self-review for spec coverage, placeholders, and type naming consistency Not-tested: Runtime behavior; this commit is implementation planning only --- .../superpowers/plans/2026-05-09-h5-access.md | 830 ++++++++++++++++++ 1 file changed, 830 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-h5-access.md diff --git a/docs/superpowers/plans/2026-05-09-h5-access.md b/docs/superpowers/plans/2026-05-09-h5-access.md new file mode 100644 index 00000000..e6c1e418 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-h5-access.md @@ -0,0 +1,830 @@ +# H5 Access Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add opt-in personal/team H5 browser access with a generated token, controlled origin handling, authenticated REST/WebSocket access, and a mobile-safe chat shell without disrupting the default desktop/Tauri flow. + +**Architecture:** Store H5 access configuration under the existing cc-haha managed settings file, expose a narrow H5 settings API for the desktop Settings UI, and validate the generated token in the shared server auth path when remote access is required. The browser H5 runtime reuses the current Vite SPA with a connection screen, central REST/WebSocket auth injection, and responsive chat layout adjustments. + +**Tech Stack:** Bun server, TypeScript, React 18, Zustand, Vite, Vitest, Testing Library, existing REST/WebSocket server. + +--- + +## File Structure + +- Create `src/server/services/h5AccessService.ts`: H5 settings persistence, token generation, token hashing, sanitized response shaping, token validation. +- Create `src/server/api/h5-access.ts`: H5 Settings API endpoints for status, enable, disable, regenerate, update, and verify. +- Modify `src/server/router.ts`: route `/api/h5-access`. +- Modify `src/server/middleware/auth.ts`: accept either existing Anthropic bearer auth or enabled H5 token auth when auth is required. +- Modify `src/server/middleware/cors.ts`: make allowed origins configurable through H5 settings and keep localhost/Tauri defaults. +- Modify `src/server/index.ts`: use async CORS/auth helpers for REST, proxy, and client WebSocket upgrades. +- Add/modify server tests under `src/server/__tests__/`. +- Create `desktop/src/api/h5Access.ts`: typed client for H5 settings endpoints. +- Modify `desktop/src/api/client.ts`: auth token storage/injection helpers for browser H5 mode. +- Modify `desktop/src/api/websocket.ts`: `http -> ws`, `https -> wss`, and token query injection. +- Modify `desktop/src/lib/desktopRuntime.ts`: read/write H5 browser connection state and distinguish startup health from authenticated verification. +- Create `desktop/src/components/layout/H5ConnectionView.tsx`: browser-only connection form. +- Modify `desktop/src/components/layout/AppShell.tsx`: show H5 connection view on browser auth startup failure; keep Tauri startup unchanged. +- Modify `desktop/src/stores/settingsStore.ts`, `desktop/src/types/settings.ts`, and `desktop/src/pages/Settings.tsx`: Settings UI state and controls for H5 enable/token URL management. +- Modify `desktop/src/i18n/locales/en.ts` and `desktop/src/i18n/locales/zh.ts`: new H5 labels. +- Create `desktop/src/hooks/useMobileViewport.ts`: shared mobile breakpoint hook. +- Modify `desktop/src/theme/globals.css`, `desktop/src/components/layout/AppShell.tsx`, `desktop/src/components/layout/Sidebar.tsx`, `desktop/src/pages/ActiveSession.tsx`, `desktop/src/components/chat/ChatInput.tsx`, and `desktop/src/components/controls/PermissionModeSelector.tsx`: mobile shell and touch-target improvements. +- Add/modify desktop tests under `desktop/src/**`. +- Create `docs/desktop/h5-access.md`: concise user setup notes for LAN/reverse proxy mode. + +## Task 1: Server H5 Settings Service And API + +**Files:** +- Create: `src/server/services/h5AccessService.ts` +- Create: `src/server/api/h5-access.ts` +- Modify: `src/server/router.ts` +- Test: `src/server/__tests__/h5-access-service.test.ts` +- Test: `src/server/__tests__/h5-access-api.test.ts` + +- [ ] **Step 1: Write service tests first** + +Add tests covering default disabled state, token generation, hash-only persistence, token regeneration invalidating old tokens, and unknown field preservation. + +```ts +// src/server/__tests__/h5-access-service.test.ts +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { H5AccessService } from '../services/h5AccessService.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-service-')) + process.env.CLAUDE_CONFIG_DIR = tmpDir +}) + +afterEach(async () => { + delete process.env.CLAUDE_CONFIG_DIR + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe('H5AccessService', () => { + it('defaults to disabled without creating settings', async () => { + const service = new H5AccessService() + await expect(service.getSettings()).resolves.toMatchObject({ + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }) + }) + + it('generates a raw token once and stores only a hash plus preview', async () => { + const service = new H5AccessService() + const result = await service.enable() + expect(result.token).toMatch(/^h5_[A-Za-z0-9_-]{43,}$/) + expect(result.settings.enabled).toBe(true) + expect(result.settings.tokenPreview).toMatch(/^h5_.+\\.{3}.+$/) + expect(await service.validateToken(result.token)).toBe(true) + + const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8') + expect(raw).toContain('h5Access') + expect(raw).not.toContain(result.token) + }) + + it('preserves unrelated managed settings fields', async () => { + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'settings.json'), + JSON.stringify({ env: { ANTHROPIC_MODEL: 'keep-me' }, futureField: { keep: true } }), + 'utf-8', + ) + + const service = new H5AccessService() + await service.enable() + const saved = JSON.parse(await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')) + expect(saved.env.ANTHROPIC_MODEL).toBe('keep-me') + expect(saved.futureField).toEqual({ keep: true }) + }) + + it('regenerates token and rejects the previous token', async () => { + const service = new H5AccessService() + const first = await service.enable() + const second = await service.regenerateToken() + expect(second.token).not.toBe(first.token) + expect(await service.validateToken(first.token)).toBe(false) + expect(await service.validateToken(second.token)).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run service tests and verify they fail** + +Run: `bun test src/server/__tests__/h5-access-service.test.ts` + +Expected: FAIL because `H5AccessService` does not exist. + +- [ ] **Step 3: Implement `H5AccessService`** + +Create the service with these exports and behavior: + +```ts +export type H5AccessSettings = { + enabled: boolean + tokenPreview: string | null + allowedOrigins: string[] + publicBaseUrl: string | null +} + +export type H5AccessEnableResult = { + settings: H5AccessSettings + token: string +} + +export class H5AccessService { + async getSettings(): Promise + async enable(): Promise + async disable(): Promise + async regenerateToken(): Promise + async updateSettings(input: { + allowedOrigins?: string[] + publicBaseUrl?: string | null + }): Promise + async validateToken(token: string | null | undefined): Promise + async isOriginAllowed(origin: string | null | undefined): Promise +} +``` + +Implementation details: + +- Use `process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')`. +- Persist under `cc-haha/settings.json` in an `h5Access` object. +- Use `crypto.randomBytes(32).toString('base64url')` with `h5_` prefix for raw tokens. +- Hash with SHA-256 and store `tokenHash`, not raw token. +- Preview format: first 7 chars, ellipsis, last 4 chars. +- Normalize `allowedOrigins` to valid `http://` or `https://` origins only, no path, no wildcard. +- Preserve unknown fields by reading current JSON, replacing only `h5Access`, and writing back. + +- [ ] **Step 4: Run service tests and verify they pass** + +Run: `bun test src/server/__tests__/h5-access-service.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Write API tests** + +Add route tests for sanitized status, enable returning raw token once, verify accepting bearer token, and disable rejecting the old token. + +```ts +// src/server/__tests__/h5-access-api.test.ts +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { handleApiRequest } from '../router.js' + +let tmpDir: string + +async function api(method: string, pathname: string, body?: unknown, token?: string) { + const headers: Record = { 'Content-Type': 'application/json' } + if (token) headers.Authorization = `Bearer ${token}` + return handleApiRequest( + new Request(`http://localhost${pathname}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }), + new URL(`http://localhost${pathname}`), + ) +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-api-')) + process.env.CLAUDE_CONFIG_DIR = tmpDir +}) + +afterEach(async () => { + delete process.env.CLAUDE_CONFIG_DIR + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe('/api/h5-access', () => { + it('enables access and returns raw token only in mutation response', async () => { + const enabled = await api('POST', '/api/h5-access/enable') + expect(enabled.status).toBe(200) + const payload = await enabled.json() + expect(payload.token).toMatch(/^h5_/) + expect(payload.settings.enabled).toBe(true) + + const status = await api('GET', '/api/h5-access') + const statusPayload = await status.json() + expect(statusPayload.token).toBeUndefined() + expect(statusPayload.settings.tokenPreview).toBe(payload.settings.tokenPreview) + }) + + it('verifies a good token and rejects a bad token', async () => { + const enabled = await api('POST', '/api/h5-access/enable') + const { token } = await enabled.json() + + expect((await api('POST', '/api/h5-access/verify', undefined, token)).status).toBe(200) + expect((await api('POST', '/api/h5-access/verify', undefined, 'bad-token')).status).toBe(401) + }) +}) +``` + +- [ ] **Step 6: Implement API route and router case** + +`src/server/api/h5-access.ts` handles: + +- `GET /api/h5-access` +- `POST /api/h5-access/enable` +- `POST /api/h5-access/disable` +- `POST /api/h5-access/regenerate` +- `PUT /api/h5-access` +- `POST /api/h5-access/verify` + +Add `case 'h5-access': return handleH5AccessApi(req, url, segments)` to `src/server/router.ts`. + +- [ ] **Step 7: Run API tests** + +Run: `bun test src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 1** + +```bash +git add src/server/services/h5AccessService.ts src/server/api/h5-access.ts src/server/router.ts src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts +git commit -m "Add managed H5 access settings service" +``` + +Use Lore trailers in the commit body: `Constraint`, `Rejected`, `Confidence`, `Scope-risk`, `Tested`, and `Not-tested`. + +## Task 2: Server Auth, CORS, And WebSocket Integration + +**Files:** +- Modify: `src/server/middleware/auth.ts` +- Modify: `src/server/middleware/cors.ts` +- Modify: `src/server/index.ts` +- Test: `src/server/__tests__/h5-access-auth.test.ts` +- Test: existing `src/server/__tests__/e2e/full-flow.test.ts` + +- [ ] **Step 1: Write auth/CORS tests** + +Create tests that start the server bound to `0.0.0.0` on a random port and assert: + +- H5 disabled + no Anthropic key rejects `/api/status`. +- H5 enabled + wrong token rejects `/api/status`. +- H5 enabled + correct token allows `/api/status`. +- CORS rejects unlisted origins. +- CORS allows configured origins and includes `Vary: Origin`. +- Client WebSocket upgrade rejects missing/wrong token when auth is required. + +```ts +// src/server/__tests__/h5-access-auth.test.ts +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { startServer } from '../index.js' +import { H5AccessService } from '../services/h5AccessService.js' + +let tmpDir: string +let server: ReturnType | null = null + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-auth-')) + process.env.CLAUDE_CONFIG_DIR = tmpDir + delete process.env.ANTHROPIC_API_KEY +}) + +afterEach(async () => { + server?.stop(true) + server = null + delete process.env.CLAUDE_CONFIG_DIR + delete process.env.ANTHROPIC_API_KEY + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +function freePort() { + return 46000 + Math.floor(Math.random() * 1000) +} + +describe('H5 remote auth', () => { + it('allows remote API requests with enabled H5 token', async () => { + const service = new H5AccessService() + const { token } = await service.enable() + await service.updateSettings({ allowedOrigins: ['http://phone.local:1420'] }) + + const port = freePort() + server = startServer(port, '0.0.0.0') + + const ok = await fetch(`http://127.0.0.1:${port}/api/status`, { + headers: { + Authorization: `Bearer ${token}`, + Origin: 'http://phone.local:1420', + }, + }) + expect(ok.status).toBe(200) + expect(ok.headers.get('Access-Control-Allow-Origin')).toBe('http://phone.local:1420') + expect(ok.headers.get('Vary')).toContain('Origin') + + const bad = await fetch(`http://127.0.0.1:${port}/api/status`, { + headers: { Authorization: 'Bearer bad-token', Origin: 'http://phone.local:1420' }, + }) + expect(bad.status).toBe(401) + }) +}) +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: `bun test src/server/__tests__/h5-access-auth.test.ts --timeout 30000` + +Expected: FAIL because auth/CORS do not consult H5 settings yet. + +- [ ] **Step 3: Extend auth middleware** + +Change `auth.ts` to expose an async helper: + +```ts +export async function requireAuth(req: Request): Promise { + const existing = validateAuth(req) + if (existing.valid) return null + + const token = extractBearerToken(req) + if (await new H5AccessService().validateToken(token)) return null + + return Response.json( + { error: 'Unauthorized', message: existing.error || 'Invalid authorization token' }, + { status: 401 }, + ) +} +``` + +Keep `validateAuth()` available for existing tests. Add `extractBearerToken(req)` as a small exported helper for WebSocket query-token tests if useful. + +- [ ] **Step 4: Make CORS origin-aware** + +Change `corsHeaders(origin)` to `async corsHeaders(origin)` or add `corsHeadersForOrigin(origin, h5Service)` and update callers in `index.ts`. + +Rules: + +- Always allow localhost, `127.0.0.1`, `tauri.localhost`, `tauri://localhost`, and `asset://localhost`. +- If H5 is enabled, allow exact origins in `h5Access.allowedOrigins`. +- Never wildcard. +- Always set `Vary: Origin`. + +- [ ] **Step 5: Update `src/server/index.ts`** + +Update all call sites that currently use `corsHeaders(origin)` and `requireAuth(req)`: + +- `OPTIONS` +- `/ws/*` +- `/api/*` +- `/proxy/*` +- `/health` +- error responses in API/proxy catch blocks + +For WebSocket auth, accept either `Authorization` header or `?token=` by constructing a request-like check or by adding `validateRequestToken(req, url.searchParams.get('token'))`. + +- [ ] **Step 6: Run focused auth/server tests** + +Run: + +```bash +bun test src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/settings.test.ts src/server/__tests__/e2e/full-flow.test.ts --timeout 30000 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add src/server/middleware/auth.ts src/server/middleware/cors.ts src/server/index.ts src/server/__tests__/h5-access-auth.test.ts +git commit -m "Protect remote H5 access with token auth" +``` + +## Task 3: Desktop H5 Settings UI + +**Files:** +- Create: `desktop/src/api/h5Access.ts` +- Modify: `desktop/src/types/settings.ts` +- Modify: `desktop/src/stores/settingsStore.ts` +- Modify: `desktop/src/pages/Settings.tsx` +- Modify: `desktop/src/i18n/locales/en.ts` +- Modify: `desktop/src/i18n/locales/zh.ts` +- Test: `desktop/src/__tests__/generalSettings.test.tsx` + +- [ ] **Step 1: Write UI/store tests first** + +Extend `generalSettings.test.tsx` with: + +- H5 section renders disabled state. +- Enabling calls `enableH5Access`. +- Regenerating token calls `regenerateH5AccessToken`. +- Updating allowed origin/public URL calls `updateH5AccessSettings`. + +Use the existing `useSettingsStore.setState()` pattern in that file. + +- [ ] **Step 2: Add typed API client** + +Create `desktop/src/api/h5Access.ts`: + +```ts +import { api } from './client' + +export type H5AccessSettings = { + enabled: boolean + tokenPreview: string | null + allowedOrigins: string[] + publicBaseUrl: string | null +} + +export type H5AccessStatus = { settings: H5AccessSettings } +export type H5AccessTokenResult = { settings: H5AccessSettings; token: string } + +export const h5AccessApi = { + get() { + return api.get('/api/h5-access') + }, + enable() { + return api.post('/api/h5-access/enable') + }, + disable() { + return api.post('/api/h5-access/disable') + }, + regenerate() { + return api.post('/api/h5-access/regenerate') + }, + update(input: { allowedOrigins?: string[]; publicBaseUrl?: string | null }) { + return api.put('/api/h5-access', input) + }, +} +``` + +- [ ] **Step 3: Extend settings store** + +Add state and actions: + +```ts +h5Access: H5AccessSettings +h5AccessGeneratedToken: string | null +fetchH5Access: () => Promise +enableH5Access: () => Promise +disableH5Access: () => Promise +regenerateH5AccessToken: () => Promise +updateH5AccessSettings: (input: { allowedOrigins?: string[]; publicBaseUrl?: string | null }) => Promise +clearH5AccessGeneratedToken: () => void +``` + +Call `fetchH5Access()` inside `fetchAll()` alongside existing settings requests. + +- [ ] **Step 4: Add Settings UI section** + +In `GeneralSettings`, add a section after System Notifications and before WebFetch: + +- checkbox `Enable H5 access` +- token preview row +- generated token one-time visible box with copy button +- regenerate button +- public URL input +- allowed origins textarea or comma-separated input +- copied URL display +- compact safety note + +Keep styling consistent with existing rounded settings panels. Do not create nested cards. + +- [ ] **Step 5: Add i18n strings** + +Add matching English and Chinese keys under `settings.general.h5...`. + +- [ ] **Step 6: Run focused desktop tests** + +Run: + +```bash +cd desktop && bunx vitest run src/__tests__/generalSettings.test.tsx src/stores/settingsStore.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add desktop/src/api/h5Access.ts desktop/src/types/settings.ts desktop/src/stores/settingsStore.ts desktop/src/pages/Settings.tsx desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts desktop/src/__tests__/generalSettings.test.tsx +git commit -m "Add desktop controls for H5 access" +``` + +## Task 4: Browser H5 Runtime And Auth Injection + +**Files:** +- Modify: `desktop/src/api/client.ts` +- Modify: `desktop/src/api/websocket.ts` +- Modify: `desktop/src/lib/desktopRuntime.ts` +- Create: `desktop/src/components/layout/H5ConnectionView.tsx` +- Modify: `desktop/src/components/layout/AppShell.tsx` +- Test: `desktop/src/api/client.test.ts` +- Test: `desktop/src/api/websocket.test.ts` +- Test: `desktop/src/components/layout/AppShell.test.tsx` + +- [ ] **Step 1: Write API client tests** + +Add tests asserting: + +- default local requests do not include Authorization. +- `setAuthToken('h5_x')` causes REST requests to include `Authorization: Bearer h5_x`. +- diagnostics reporting does not include token in payload. + +- [ ] **Step 2: Implement auth helpers in `client.ts`** + +Add: + +```ts +let authToken: string | null = null + +export function setAuthToken(token: string | null) { + authToken = token && token.trim() ? token.trim() : null +} + +export function getAuthToken() { + return authToken +} +``` + +In `request()`, include `Authorization` only when `authToken` is set. + +- [ ] **Step 3: Write WebSocket URL tests** + +In `websocket.test.ts`, mock `getBaseUrl()` and `getAuthToken()` for: + +- `http://127.0.0.1:3456` -> `ws://127.0.0.1:3456/ws/session` +- `https://example.com/backend` -> `wss://example.com/backend/ws/session` +- token adds `?token=` + +- [ ] **Step 4: Implement WebSocket URL builder** + +Add a small exported helper in `websocket.ts`: + +```ts +export function buildSessionWebSocketUrl(sessionId: string) { + const base = new URL(getBaseUrl()) + base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:' + base.pathname = `${base.pathname.replace(/\/$/, '')}/ws/${sessionId}` + const token = getAuthToken() + if (token) base.searchParams.set('token', token) + return base.toString() +} +``` + +Use it in `connect()`. + +- [ ] **Step 5: Add H5 connection runtime** + +In `desktopRuntime.ts`, add localStorage keys: + +```ts +const H5_SERVER_URL_KEY = 'cc-haha-h5-server-url' +const H5_TOKEN_KEY = 'cc-haha-h5-token' +``` + +Add functions: + +```ts +export function isBrowserH5Runtime() { + return !isTauriRuntime() +} + +export function readStoredH5Connection() { + return { serverUrl: localStorage.getItem(H5_SERVER_URL_KEY), token: localStorage.getItem(H5_TOKEN_KEY) } +} + +export async function saveAndVerifyH5Connection(serverUrl: string, token: string) { + setBaseUrl(serverUrl) + setAuthToken(token) + await api.post('/api/h5-access/verify') + localStorage.setItem(H5_SERVER_URL_KEY, serverUrl) + localStorage.setItem(H5_TOKEN_KEY, token) +} +``` + +Keep Tauri startup path untouched. + +- [ ] **Step 6: Add `H5ConnectionView`** + +The component accepts `initialServerUrl`, `initialError`, and `onConnected`. It renders server URL and token inputs, calls `saveAndVerifyH5Connection`, and displays concise errors. + +- [ ] **Step 7: Wire `AppShell`** + +In browser mode, if startup fails with 401 or no stored token, render `H5ConnectionView`. On successful connect, retry `initializeDesktopServerUrl()` and `fetchSettings()`. + +Do not render this connection view in Tauri mode. + +- [ ] **Step 8: Run focused desktop runtime tests** + +Run: + +```bash +cd desktop && bunx vitest run src/api/client.test.ts src/api/websocket.test.ts src/components/layout/AppShell.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 4** + +```bash +git add desktop/src/api/client.ts desktop/src/api/websocket.ts desktop/src/lib/desktopRuntime.ts desktop/src/components/layout/H5ConnectionView.tsx desktop/src/components/layout/AppShell.tsx desktop/src/api/client.test.ts desktop/src/api/websocket.test.ts desktop/src/components/layout/AppShell.test.tsx +git commit -m "Add authenticated H5 browser runtime" +``` + +## Task 5: Mobile Chat Shell + +**Files:** +- Create: `desktop/src/hooks/useMobileViewport.ts` +- Modify: `desktop/src/theme/globals.css` +- Modify: `desktop/src/components/layout/AppShell.tsx` +- Modify: `desktop/src/components/layout/Sidebar.tsx` +- Modify: `desktop/src/pages/ActiveSession.tsx` +- Modify: `desktop/src/components/chat/ChatInput.tsx` +- Modify: `desktop/src/components/controls/PermissionModeSelector.tsx` +- Test: `desktop/src/components/layout/AppShell.test.tsx` +- Test: `desktop/src/pages/ActiveSession.test.tsx` +- Test: `desktop/src/components/chat/ChatInput.test.tsx` + +- [ ] **Step 1: Add viewport hook test and implementation** + +Create `useMobileViewport.ts` with `matchMedia('(max-width: 767px)')`, SSR-safe default `false`, and cleanup of listeners. + +- [ ] **Step 2: Update shell CSS** + +Add classes in `globals.css`: + +```css +.app-shell { + height: 100vh; + height: 100dvh; +} + +@media (max-width: 767px) { + .app-shell { + min-width: 0; + } + + .sidebar-shell { + position: fixed; + inset: 0 auto 0 0; + z-index: 60; + width: min(86vw, 320px); + transform: translateX(-100%); + } + + .sidebar-shell[data-mobile-state="open"] { + transform: translateX(0); + } + + .mobile-touch-target { + min-height: 44px; + min-width: 44px; + } +} +``` + +Preserve existing desktop sidebar behavior outside the media query. + +- [ ] **Step 3: Update `AppShell` mobile drawer** + +Use `useMobileViewport()` to: + +- apply `className="app-shell flex overflow-hidden ..."` +- add `data-mobile-state` +- add a backdrop button when sidebar is open on mobile +- keep desktop `sidebarOpen` behavior unchanged + +- [ ] **Step 4: Relax mobile chat min-widths** + +In `ActiveSession.tsx`, replace unconditional `min-w-[320px]` and `min-w-[360px]` with responsive classes: + +- desktop keeps current min widths +- mobile uses `min-w-0 w-full` +- workspace and terminal resize handles do not render on mobile + +- [ ] **Step 5: Make composer touch-safe** + +In `ChatInput.tsx`, on mobile: + +- plus button uses `mobile-touch-target` +- send/stop compact button uses `h-11 w-11` +- slash and plus menus use `left-0 right-0 w-auto max-w-[calc(100vw-24px)]` +- keyboard shortcut footer in slash menu can hide on mobile + +- [ ] **Step 6: Make permission selector mobile-safe** + +In `PermissionModeSelector.tsx`, on mobile: + +- dropdown uses fixed bottom sheet or full-width anchored sheet. +- bypass confirm overlay removes `pl-[var(--sidebar-width)]`. +- dialog width becomes `w-[calc(100vw-24px)] max-w-[420px]`. + +- [ ] **Step 7: Run focused mobile tests** + +Run: + +```bash +cd desktop && bunx vitest run src/components/layout/AppShell.test.tsx src/pages/ActiveSession.test.tsx src/components/chat/ChatInput.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 5** + +```bash +git add desktop/src/hooks/useMobileViewport.ts desktop/src/theme/globals.css desktop/src/components/layout/AppShell.tsx desktop/src/components/layout/Sidebar.tsx desktop/src/pages/ActiveSession.tsx desktop/src/components/chat/ChatInput.tsx desktop/src/components/controls/PermissionModeSelector.tsx desktop/src/components/layout/AppShell.test.tsx desktop/src/pages/ActiveSession.test.tsx desktop/src/components/chat/ChatInput.test.tsx +git commit -m "Adapt chat shell for mobile H5 access" +``` + +## Task 6: H5 Documentation And Verification + +**Files:** +- Create: `docs/desktop/h5-access.md` +- Modify: `docs/.vitepress/config.mts` if desktop docs sidebar needs the new page. +- Test/verify: docs build if sidebar changes. + +- [ ] **Step 1: Write user-facing setup doc** + +Create `docs/desktop/h5-access.md` with: + +- enable H5 in Settings +- copy token +- configure allowed origin +- expose backend with LAN/reverse proxy +- open H5 URL on phone +- reset token if leaked +- TLS recommendation for domains + +- [ ] **Step 2: Add docs navigation only if desktop docs already list peer pages** + +Inspect `docs/.vitepress/config.mts`. If `docs/desktop` is sidebar-linked, add H5 page next to related desktop pages. If there is no desktop sidebar section, leave the page unlinked for now and mention the path in final handoff. + +- [ ] **Step 3: Run docs check if navigation changed** + +Run: `bun run check:docs` + +Expected: PASS. If it fails due pre-existing npm lock/dependency drift, record the blocker and do not modify lockfiles unless the failure is caused by this docs change. + +- [ ] **Step 4: Run full targeted gates** + +Run: + +```bash +bun run check:server +bun run check:desktop +``` + +Then run: + +```bash +bun run verify +``` + +Expected: PASS or explicit pre-existing blocker from the quality report. + +- [ ] **Step 5: Browser/mobile smoke** + +Start backend and frontend on isolated ports: + +```bash +SERVER_PORT=3456 bun run src/server/index.ts --host 127.0.0.1 +cd desktop && VITE_DESKTOP_SERVER_URL=http://127.0.0.1:3456 bun run dev -- --host 127.0.0.1 --port 1422 +``` + +Use browser automation at 390x844 and 430x932 to verify: + +- H5 connection view renders in browser mode without stored token. +- token entry reaches sessions list after server is configured. +- session list and chat column do not overflow viewport. +- send/stop controls are visible and touch-sized. + +- [ ] **Step 6: Commit Task 6** + +```bash +git add docs/desktop/h5-access.md docs/.vitepress/config.mts +git commit -m "Document H5 access setup" +``` + +Only include `docs/.vitepress/config.mts` if it changed. + +## Execution Strategy + +Use subagent-driven execution with sequencing to avoid shared-file conflicts: + +1. Run Task 1 locally or with one executor. It defines server service/API contracts. +2. After Task 1 lands, dispatch one executor for Task 2. +3. In parallel after Task 1, dispatch one executor for Task 3 and one for Task 4 only if their write sets are kept separate; coordinate shared `settingsStore` and `AppShell` carefully. +4. Run Task 5 after Task 4 because both touch `AppShell`. +5. Run Task 6 last. + +Do not let multiple workers edit `desktop/src/components/layout/AppShell.tsx` at the same time. If parallel execution is used, Task 4 owns `AppShell` first; Task 5 rebases on that result. + +## Plan Self-Review + +- Spec coverage: H5 settings, token generation, CORS/auth, browser runtime, mobile shell, error handling, docs, and verification are covered by Tasks 1-6. +- Scope: This remains one implementation plan because each task is independently testable and all tasks serve the single opt-in H5 access feature. +- Ambiguity resolved: H5 config is stored in `~/.claude/cc-haha/settings.json`, not the shared `~/.claude/settings.json`. +- Desktop safety: Default localhost/Tauri behavior is explicitly preserved and covered by focused desktop tests.