diff --git a/.gitignore b/.gitignore index 6e4bc162..5cae88f4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .agents/ skills-lock.json +# Written by install.sh into end-user clones when setup finishes +.install-complete + # Dependencies node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index aa392fda..2f9b43d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # aicodeman +## 1.6.2 + +### Patch Changes + +- Installer (install.sh) reliability and safety overhaul, prompted by a review of the Linux flow: + - Install-completion marker (`.install-complete`): a bare re-run only takes the quiet update path when a previous install actually finished. Previously, a first install that failed during npm install/build (or was interrupted) left `.git` behind, so the retry silently became an "update" and the user never got the launch menu, the `codeman`/`tmux-chooser` symlinks, the PATH entry, or the `sc` alias. The marker is refreshed by updates and cleared by uninstall when the app dir is kept; added to .gitignore for end-user clones. + - `update` no longer runs an unconditional `git reset --hard` over local changes: interactive runs are asked to stash (declining keeps everything and skips the update), headless runs auto-stash with a dated message (same policy as scripts/self-update.sh). + - Service setup is verified instead of asserted: after starting codeman-web, the installer polls `systemctl --user is-active` (up to 6s) and only then prints "Codeman is running now!"; failures print an honest warning plus status/journalctl hints. Uses `restart` instead of `start` so re-running the installer over an already-running service actually loads the new build. A missing user D-Bus session (e.g. bare `ssh host 'curl | bash'`) is detected up front with copy-paste recovery commands instead of dying mid-setup via `set -e`. macOS gets the equivalent `launchctl list` verification, and the update path verifies its service restart too. The Cloudflare tunnel-service offer is skipped when service setup failed. + - Headless consent guard: with no interactive terminal AND no explicit `CODEMAN_NONINTERACTIVE=1`, the installer now refuses (with instructions) to run sudo package installs (git/node/tmux) or third-party `curl | bash` AI CLI installers, instead of silently taking the default-yes prompts. Explicit `CODEMAN_NONINTERACTIVE=1` keeps the previous full-auto behavior for CI/automation. + - AI CLI gate now recognizes Codex and Gemini (search paths mirrored from the CLI resolvers), so a box with only Codex or Gemini installed is no longer forced to install Claude Code/OpenCode. The install menu gains a "Skip" option (with npm install hints for Codex/Gemini), and the final reminder lists all four CLIs. + + Docs: CLAUDE.md documents `src/remote-reconnect.ts` (pure COD-108 auto-reconnect backoff/eligibility logic) in the Infra table and the remote-sessions pattern. + ## 1.6.1 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 28e90abf..2e5d6f65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 1.6.1 (must match `package.json`) +**Version**: 1.6.2 (must match `package.json`) ## Project Overview @@ -135,7 +135,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | | | **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | | | **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | | -| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts`, `src/remote-hosts.ts` (remote SSH hosts/cases — see Key Patterns) | | +| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts`, `src/remote-hosts.ts` (remote SSH hosts/cases — see Key Patterns), `src/remote-reconnect.ts` (pure COD-108 auto-reconnect backoff/eligibility; watcher lives in `tmux-manager.ts`) | | | **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` — see Key Patterns | | **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/generated-artifact-attachments.ts` (Codex `Saved to:` artifacts), `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | | @@ -172,10 +172,12 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`. -**Remote sessions (SSH)**: Sessions can run the agent inside a durable `tmux -L codeman-remote new-session -A` **on a remote host** so it survives the SSH drop (COD-104), and can also **discover + attach** to `codeman-*` sessions another Codeman launched there — attached (`owned:false`) sessions **detach, never kill** on tab close (COD-105). **Shared/collaborative** (COD-106): remote set-options are scoped per-session (never `-g`) and `window-size latest` lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. **Auto-reconnect** (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch `remoteAutoReconnect`, default ON). Owned sessions propagate `kill-session` to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe `buildSshConnectionArgs()` — every user field (`-J jumpHost`, `-i identity`, `-o`) is `shellescape`d; never hand-build an ssh line elsewhere. Full design: `docs/remote-sessions.md`. +**Remote sessions (SSH)**: Sessions can run the agent inside a durable `tmux -L codeman-remote new-session -A` **on a remote host** so it survives the SSH drop (COD-104), and can also **discover + attach** to `codeman-*` sessions another Codeman launched there — attached (`owned:false`) sessions **detach, never kill** on tab close (COD-105). **Shared/collaborative** (COD-106): remote set-options are scoped per-session (never `-g`) and `window-size latest` lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. **Auto-reconnect** (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch `remoteAutoReconnect`, default ON); the pure pieces (backoff schedule, per-session reconnect state, `decideReconnect` eligibility) live in `src/remote-reconnect.ts` (tests: `test/remote-auto-reconnect.test.ts`), while `tmux-manager.ts` owns the live pane probe + timers. Owned sessions propagate `kill-session` to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe `buildSshConnectionArgs()` — every user field (`-J jumpHost`, `-i identity`, `-o`) is `shellescape`d; never hand-build an ssh line elsewhere. Full design: `docs/remote-sessions.md`. **External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). +**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w-` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`. + **Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. **Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the five CLI backends runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}` (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`. @@ -230,6 +232,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Custom branding + UI language** (App Settings → Display → Branding & Language): `displayName` is schema-validated (trimmed, 1–40 chars), server-synced, and changes user-facing browser branding/window titles only — NEVER rename npm package/CLI/API/storage/CSS/protocol identifiers. `language` is a per-device `en`/`zh-CN` display key, stripped from the server payload. `i18n.js` keeps English as the canonical source/fallback, observes newly inserted application DOM for dynamic copy, preserves source strings so live EN↔ZH switching is reversible, and skips terminal/response/file/session-name/user-content surfaces. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. +**Foldable settings identity**: responsive layout remains width-driven through `MobileDetection.getDeviceType()`, but the localStorage namespace/defaults use `MobileDetection.isHandheldDevice()` so an Android foldable keeps `codeman-app-settings-mobile` after unfolding past the desktop breakpoint. The stable handheld check prefers explicit phone/tablet/desktop UA tokens, then `navigator.userAgentData.mobile`; Android WebView is covered by the `Mobile` UA fallback. Do not switch per-device settings namespaces from instantaneous viewport width — a posture-triggered WebView reload would lose opt-in UI such as `showResponseViewer` and `extendedKeyboardBar`. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). **Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry (see Command palette above). @@ -249,7 +252,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L | **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter | | **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | | **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | -| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) | +| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | ### SSE Event Registry @@ -298,7 +301,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa **Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests. -**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`. +**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (136 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`. ## Debugging diff --git a/README.md b/README.md index a717af89..5879ddaf 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,13 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash ``` -This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. +This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing: -You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). After install: +- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it. +- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist. +- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation. + +You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). The installer detects whichever of the four is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: ```bash codeman web @@ -53,6 +57,8 @@ Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
Run as a background service +The installer's final menu sets this up for you (option 2) and verifies the service actually comes up before claiming success. To configure it manually instead: + **Linux (systemd):** ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 728681a6..68955276 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,9 +34,13 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash ``` -该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。 +该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知: -你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装完成后: +- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。 +- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。 +- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。 + +你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装器会自动检测这四个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后: ```bash codeman web @@ -55,6 +59,8 @@ codeman web --multiuser # 命名登录 + 按用户隔离的案例
作为后台服务运行 +安装器结尾的菜单(选项 2)可以帮你完成这一步,并在宣告成功前校验服务确实已启动。如需手动配置: + **Linux(systemd):** ```bash diff --git a/install.sh b/install.sh index 8c19bf99..56edb483 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,11 @@ # Usage: curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash # # Environment variables: -# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation) +# CODEMAN_NONINTERACTIVE=1 - Skip all prompts and accept their defaults +# (CI/automation). Required for headless runs +# that need system changes (sudo package +# installs, AI CLI download); without it those +# steps abort instead of running silently. # CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app) # CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt # CODEMAN_NODE_VERSION - Node.js major version to install (default: 22) @@ -53,6 +57,26 @@ OPENCODE_SEARCH_PATHS=( "$HOME/bin/opencode" ) +# Codex CLI search paths (from src/utils/codex-cli-resolver.ts) +CODEX_SEARCH_PATHS=( + "$HOME/.codex/bin/codex" + "$HOME/.local/bin/codex" + "/usr/local/bin/codex" + "$HOME/.bun/bin/codex" + "$HOME/.npm-global/bin/codex" + "$HOME/bin/codex" +) + +# Gemini CLI search paths (from src/utils/gemini-cli-resolver.ts) +GEMINI_SEARCH_PATHS=( + "$HOME/.gemini/bin/gemini" + "$HOME/.local/bin/gemini" + "/usr/local/bin/gemini" + "$HOME/.bun/bin/gemini" + "$HOME/.npm-global/bin/gemini" + "$HOME/bin/gemini" +) + # ============================================================================ # Color Output # ============================================================================ @@ -336,6 +360,62 @@ get_opencode_path() { done } +check_codex() { + if command -v codex &>/dev/null; then + return 0 + fi + + for path in "${CODEX_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]]; then + return 0 + fi + done + + return 1 +} + +get_codex_path() { + if command -v codex &>/dev/null; then + command -v codex + return + fi + + for path in "${CODEX_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]]; then + echo "$path" + return + fi + done +} + +check_gemini() { + if command -v gemini &>/dev/null; then + return 0 + fi + + for path in "${GEMINI_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]]; then + return 0 + fi + done + + return 1 +} + +get_gemini_path() { + if command -v gemini &>/dev/null; then + command -v gemini + return + fi + + for path in "${GEMINI_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]]; then + echo "$path" + return + fi + done +} + check_cloudflared() { # Check ~/.local/bin first (matches tunnel-manager.ts resolution order) if [[ -x "$HOME/.local/bin/cloudflared" ]]; then @@ -701,6 +781,21 @@ read_reply() { fi } +# headless_guard : refuse consequential system changes (sudo package +# installs, third-party curl | bash installers) when nobody can consent, i.e. +# no terminal AND no explicit CODEMAN_NONINTERACTIVE=1 opt-in. Interactive +# runs fall through to their normal prompt; opted-in automation proceeds with +# the prompt defaults as before. +headless_guard() { + local action="$1" + if [[ "$NONINTERACTIVE" == "1" ]] || has_tty; then + return 0 + fi + error "No interactive terminal, but the installer would need to: $action." + error "Re-run from a terminal to be prompted, or set CODEMAN_NONINTERACTIVE=1 to approve such steps in automation." + exit 1 +} + prompt_yes_no() { local prompt="$1" local default="${2:-y}" @@ -835,6 +930,20 @@ setup_sc_alias() { # Service Setup (Linux systemd / macOS launchd) # ============================================================================ +# Wait briefly for codeman-web.service to report active. A bad node path or a +# busy port makes the unit crash within the first seconds (then sit in +# activating/auto-restart), so a blind "started!" message would be a lie. +verify_systemd_active() { + local attempt + for attempt in 1 2 3; do + sleep 2 + if systemctl --user is-active --quiet codeman-web.service 2>/dev/null; then + return 0 + fi + done + return 1 +} + setup_launchd_service() { local plist_label="com.codeman.web" local agent_dir="$HOME/Library/LaunchAgents" @@ -907,7 +1016,15 @@ EOF launchctl load "$agent_plist" 2>/dev/null || true - success "LaunchAgent installed and started" + # launchctl load is silent about many failures: confirm the agent is loaded + sleep 2 + if launchctl list "$plist_label" &>/dev/null; then + success "LaunchAgent installed and started" + return 0 + fi + warn "LaunchAgent did not load." + warn "Inspect: launchctl list | grep codeman ; tail -20 /tmp/codeman.log" + return 1 } setup_systemd_service() { @@ -941,8 +1058,15 @@ Environment=PATH=$PATH WantedBy=default.target EOF - # Reload systemd - systemctl --user daemon-reload + # Reload systemd. A user D-Bus session is required for systemctl --user + # (missing under bare `ssh host 'curl | bash'` provisioning), so detect + # that up front instead of dying mid-setup with a cryptic trap message. + if ! systemctl --user daemon-reload 2>/dev/null; then + warn "systemctl --user is unavailable (no user D-Bus session?); cannot manage user services here." + warn "Unit written to $service_file. From a normal login shell, enable it with:" + warn " systemctl --user daemon-reload && systemctl --user enable --now codeman-web" + return 1 + fi # Enable service systemctl --user enable codeman-web.service 2>/dev/null || true @@ -952,10 +1076,17 @@ EOF loginctl enable-linger "$USER" 2>/dev/null || true fi - # Start the service immediately - systemctl --user start codeman-web.service 2>/dev/null || true + # (Re)start the service. restart, not start: on a re-run over an existing + # running service, start would be a no-op and leave the OLD build running. + systemctl --user restart codeman-web.service 2>/dev/null || true - success "Systemd service installed and started" + if verify_systemd_active; then + success "Systemd service installed and started" + return 0 + fi + warn "codeman-web.service did not become active." + warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e" + return 1 } setup_tunnel_service() { @@ -1039,6 +1170,7 @@ main() { # Git info "Checking Git..." if ! check_git; then + headless_guard "install Git (system package via sudo)" if prompt_yes_no "Git is not installed. Install it now?"; then install_dependency "git" "$os" "$distro" else @@ -1057,6 +1189,7 @@ main() { warn "Node.js $node_version is installed but version $MIN_NODE_VERSION+ is required." fi + headless_guard "install Node.js v$TARGET_NODE_VERSION (system package via sudo)" if prompt_yes_no "Install Node.js v$TARGET_NODE_VERSION?"; then install_dependency "node" "$os" "$distro" @@ -1081,6 +1214,7 @@ main() { if check_tmux; then success "tmux is installed" else + headless_guard "install tmux (system package via sudo)" if prompt_yes_no "tmux is not installed. Install it now?"; then install_dependency "tmux" "$os" "$distro" else @@ -1088,9 +1222,11 @@ main() { fi fi - # AI CLI (at least one required: Claude Code or OpenCode) + # AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini) local has_claude=false local has_opencode=false + local has_codex=false + local has_gemini=false info "Checking AI CLI tools..." if check_claude; then @@ -1101,29 +1237,39 @@ main() { has_opencode=true success "OpenCode found at $(get_opencode_path)" fi + if check_codex; then + has_codex=true + success "Codex found at $(get_codex_path)" + fi + if check_gemini; then + has_gemini=true + success "Gemini CLI found at $(get_gemini_path)" + fi - if [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then + if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" ]]; then echo "" - warn "No AI CLI found. Codeman requires at least one: Claude Code or OpenCode." + warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, or Gemini." + headless_guard "install an AI CLI (curl | bash from its vendor)" echo "" echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)" echo -e " ${CYAN}2)${NC} OpenCode (open-source)" echo -e " ${CYAN}3)${NC} Both" + echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex or Gemini)" echo "" local cli_choice="" if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then - # Non-interactive: default to Claude Code + # Explicit automation opt-in: default to Claude Code cli_choice="1" - info "No interactive terminal detected: defaulting to Claude Code" + info "CODEMAN_NONINTERACTIVE=1: defaulting to Claude Code" else while true; do - echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2 + echo -en "${CYAN}Choose [1/2/3/4]:${NC} " >&2 read_reply cli_choice || { cli_choice="1"; break; } case "$cli_choice" in - 1|2|3) break ;; - *) echo "Please enter 1, 2, or 3." >&2 ;; + 1|2|3|4) break ;; + *) echo "Please enter 1, 2, 3, or 4." >&2 ;; esac done fi @@ -1152,8 +1298,12 @@ main() { fi fi - if [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then - die "At least one AI CLI is required. Install manually and re-run the installer." + if [[ "$cli_choice" == "4" ]]; then + warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." + info "Install one later, e.g.: npm install -g @openai/codex (Codex)" + info " or: npm install -g @google/gemini-cli (Gemini)" + elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then + die "The selected AI CLI failed to install. Install one manually and re-run the installer." fi fi @@ -1249,6 +1399,16 @@ main() { fi fi + # ======================================================================== + # Mark install complete + # ======================================================================== + + # The dispatcher at the bottom only routes a bare re-run to the quiet + # update path when this marker exists, so an aborted first install + # (failed npm install/build, Ctrl+C) re-runs the full setup flow + # (symlinks, PATH, launch menu) instead of silently "updating". + date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete" + # ======================================================================== # Launch Options # ======================================================================== @@ -1325,14 +1485,16 @@ main() { # Handle service setup if [[ "$launch_choice" == "2" ]]; then + local service_ok=true if [[ "$service_type" == "launchd" ]]; then - setup_launchd_service + setup_launchd_service || service_ok=false else - setup_systemd_service + setup_systemd_service || service_ok=false fi - # Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service) - if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then + # Offer tunnel service if cloudflared is available (Linux only: systemd tunnel service). + # Skipped when service setup failed: it needs the same systemctl --user access. + if [[ "$service_ok" == "true" ]] && [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then echo "" if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then setup_tunnel_service @@ -1340,10 +1502,15 @@ main() { fi echo "" - echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}" - echo "" - echo -e " ${CYAN}# Open in browser${NC}" - echo -e " http://localhost:3000" + if [[ "$service_ok" == "true" ]]; then + echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}" + echo "" + echo -e " ${CYAN}# Open in browser${NC}" + echo -e " http://localhost:3000" + else + echo -e " ${YELLOW}${BOLD}The service was set up but is not running yet${NC} (see warnings above)." + echo -e " ${DIM}You can always run it directly:${NC} ${CYAN}codeman web${NC}" + fi echo "" echo -e " ${BOLD}Manage the service:${NC}" echo "" @@ -1392,10 +1559,12 @@ main() { echo -e " https://github.com/Ark0N/Codeman" echo "" - if ! check_claude && ! check_opencode; then + if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini; then echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:" echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code" echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode" + echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex" + echo -e " ${CYAN}npm install -g @google/gemini-cli${NC} # Gemini" echo "" fi @@ -1427,10 +1596,26 @@ update() { info "Updating Codeman..." cd "$INSTALL_DIR" git remote set-url origin "$REPO_URL" 2>/dev/null || true + + # Never blow away local changes silently (this used to be an unconditional + # reset --hard). Interactive users get a choice; headless runs auto-stash + # so the changes stay recoverable, the same policy as scripts/self-update.sh. + if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then + warn "Local changes detected in $INSTALL_DIR" + if prompt_yes_no "Stash local changes and update? (recover with: git stash pop)"; then + git stash push --quiet -m "codeman-installer auto-stash $(date -u +%Y-%m-%dT%H:%M:%SZ)" + info "Local changes stashed (see 'git stash list' in $INSTALL_DIR)" + else + info "Keeping local changes; update skipped." + return 0 + fi + fi + git fetch --quiet origin git reset --hard "origin/$BRANCH" --quiet npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit npm run build --quiet 2>/dev/null || npm run build + date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete" success "Updated to $(node -e "console.log(require('./package.json').version)")" echo "" @@ -1438,8 +1623,13 @@ update() { local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist" if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then info "Restarting codeman-web service..." - systemctl --user restart codeman-web.service - success "codeman-web service restarted" + systemctl --user restart codeman-web.service 2>/dev/null || true + if verify_systemd_active; then + success "codeman-web service restarted" + else + warn "codeman-web.service did not come back up." + warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e" + fi elif [[ -f "$agent_plist" ]]; then info "Restarting LaunchAgent..." launchctl unload "$agent_plist" 2>/dev/null || true @@ -1508,6 +1698,9 @@ uninstall() { rm -rf "$INSTALL_DIR" success "Removed $INSTALL_DIR" else + # Clear the marker so a future installer run does full setup again + # (the symlinks and services being removed here need recreating). + rm -f "$INSTALL_DIR/.install-complete" info "Kept $INSTALL_DIR" fi fi @@ -1537,7 +1730,10 @@ case "${1:-}" in update) update ;; uninstall) uninstall ;; *) - if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then + # Only a COMPLETED install re-runs as a quiet update. A partial one + # (clone succeeded but build/menu never finished) lacks the marker and + # re-runs the full flow, so a failed first attempt can actually finish. + if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" && -f "$INSTALL_DIR/.install-complete" ]]; then print_banner update else diff --git a/package-lock.json b/package-lock.json index 1106393b..0038fb62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.6.1", + "version": "1.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.6.1", + "version": "1.6.2", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index f9d091bf..05e32124 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.6.1", + "version": "1.6.2", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/utils/regex-patterns.ts b/src/utils/regex-patterns.ts index 47970a49..6cf28fc1 100644 --- a/src/utils/regex-patterns.ts +++ b/src/utils/regex-patterns.ts @@ -60,7 +60,7 @@ export function stripAnsi(text: string): string { */ export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/; -export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/; +export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u; /** * Execute a global regex pattern against data, calling the callback for each match. diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index e76e5017..5b8b99a5 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -43,6 +43,35 @@ const MobileDetection = { ); }, + /** + * Check whether this browser belongs to a handheld device. + * + * Unlike getDeviceType(), this classification must remain stable when a + * foldable changes posture. An unfolded phone can expose a desktop-width + * viewport, but it still needs the same per-device settings that were saved + * while folded. User-Agent Client Hints are preferred where available; the + * legacy token fallback covers Android WebView and iPhone browsers. + */ + isHandheldDevice() { + if (!this.isTouchDevice()) return false; + + const userAgent = navigator.userAgent || ''; + + // Prefer explicit UA form-factor signals. Besides matching real browsers, + // this avoids Chromium emulation reporting userAgentData.mobile=true for + // an iPad/tablet context created with isMobile=true. + if (/iPad|Tablet|Silk|PlayBook|Kindle|Windows NT|CrOS|Macintosh/i.test(userAgent)) { + return false; + } + if (/Android/i.test(userAgent) && !/Mobile/i.test(userAgent)) return false; + if (/Mobi|iPhone|iPod/i.test(userAgent)) return true; + + const uaDataMobile = navigator.userAgentData?.mobile; + if (typeof uaDataMobile === 'boolean') return uaDataMobile; + + return false; + }, + /** Check if device is iOS (iPhone, iPad, iPod) */ isIOS() { return ( diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index fa6af8ee..a3e01b71 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -350,19 +350,60 @@ Object.assign(CodemanApp.prototype, { return this.run(); }, + /** Ensure a newly-created session is visible without waiting for the SSE event. + * The POST response and session:created can arrive in either order, so the + * normal idempotent SSE handler remains the single state-upsert path. */ + async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) { + if (!sessionId) return; + + let session = sessionSnapshot; + if (!session && !this.sessions?.has(sessionId)) { + const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to load the new session'); + session = data.data?.session || data.data; + } + + if (session?.id) this._onSessionCreated(session); + // session:created normally uses the debounced renderer. The direct POST path + // needs the tab in the DOM before selectSession() marks it active. + this._renderSessionTabsImmediate?.(); + }, + /** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */ async run() { - const mode = this._runMode || 'claude'; - if (mode === 'opencode') { - return this.runOpenCode(); + if (this._runInFlight) return; + + const startedAt = Date.now(); + const minLockMs = Number.isFinite(this._runMinLockMs) ? this._runMinLockMs : 500; + const runBtn = document.getElementById('runBtn'); + this._runInFlight = true; + if (runBtn) { + runBtn.disabled = true; + runBtn.setAttribute('aria-busy', 'true'); } - if (mode === 'codex') { - return this.runCodex(); + + try { + const mode = this._runMode || 'claude'; + if (mode === 'opencode') { + return await this.runOpenCode(); + } + if (mode === 'codex') { + return await this.runCodex(); + } + if (mode === 'gemini') { + return await this.runGemini(); + } + return await this.runClaude(); + } finally { + const remaining = minLockMs - (Date.now() - startedAt); + if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)); + this._runInFlight = false; + if (runBtn) { + runBtn.disabled = false; + runBtn.removeAttribute('aria-busy'); + } } - if (mode === 'gemini') { - return this.runGemini(); - } - return this.runClaude(); }, // Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of @@ -596,6 +637,7 @@ Object.assign(CodemanApp.prototype, { } } if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); remoteIds.push(data.data.sessionId); } this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`); @@ -659,6 +701,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); + await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session); sessionIds.push(result.data.session.id); } firstSessionId = sessionIds[0]; @@ -774,6 +817,7 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start remote shell session'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); remoteIds.push(data.data.sessionId); } if (remoteIds[0]) { @@ -807,6 +851,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); + await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session); sessionIds.push(result.data.session.id); } @@ -884,6 +929,7 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start OpenCode'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); // Switch to the new session (don't pre-set activeSessionId — selectSession // early-returns when IDs match, skipping buffer load and sendResize) @@ -940,6 +986,7 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start Codex'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); // Switch to the new session (don't pre-set activeSessionId — selectSession // early-returns when IDs match, skipping buffer load and sendResize) @@ -992,6 +1039,7 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start Gemini'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); if (data.data.sessionId) { await this.selectSession(data.data.sessionId); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 74e90b3f..d30b4206 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1768,17 +1768,21 @@ Object.assign(CodemanApp.prototype, { return settings.ralphTrackerEnabled ?? false; }, - // Get the settings storage key based on device type (mobile vs desktop) + // Keep the settings namespace stable across foldable posture changes. Layout + // still follows viewport width, but an unfolded phone remains the same + // handheld device and must not silently switch to desktop preferences. getSettingsStorageKey() { - const isMobile = MobileDetection.getDeviceType() === 'mobile'; - return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings'; + const isHandheld = + MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile'; + return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings'; }, // Get default settings based on device type // Note: Notification prefs are handled separately by NotificationManager getDefaultSettings() { - const isMobile = MobileDetection.getDeviceType() === 'mobile'; - if (isMobile) { + const isHandheld = + MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile'; + if (isHandheld) { // Mobile defaults: minimal UI for small screens return { // Header visibility - hide everything on mobile @@ -2203,7 +2207,7 @@ Object.assign(CodemanApp.prototype, { // so mobile defaults to OFF; the desktop blob is untouched and keeps its value. try { if ( - MobileDetection.getDeviceType() === 'mobile' && + (MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile') && !localStorage.getItem('codeman:planUsagePerDeviceMigrated') ) { const s = this.loadAppSettingsFromStorage(); diff --git a/src/web/server.ts b/src/web/server.ts index 4697ffef..6a1177c8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1226,7 +1226,11 @@ export class WebServer extends EventEmitter { const configuredDisplayName = typeof persistedSettings.displayName === 'string' ? persistedSettings.displayName.trim() : ''; const displayName = configuredDisplayName || 'Codeman'; - this.windowTitle = `${displayName === 'Codeman' ? 'codeman' : displayName}:${this.titleHostname}`; + // Solo renders read no settings; recomputing here would reset the shared + // push-notification prefix (hostTitle) to the default name. + if (!soloSessionId) { + this.windowTitle = `${displayName === 'Codeman' ? 'codeman' : displayName}:${this.titleHostname}`; + } let html = this.indexHtmlTemplate.replace( 'Codeman', `${escapeHtmlText(this.windowTitle)}` diff --git a/test/mobile/README.md b/test/mobile/README.md index 36044a15..65d89ea0 100644 --- a/test/mobile/README.md +++ b/test/mobile/README.md @@ -2,11 +2,11 @@ Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit). -**325 tests across 135 devices — all passing.** +**326 tests across 136 devices — all passing.** ## Purpose -Validates Codeman's mobile UI across 135 devices, covering: +Validates Codeman's mobile UI across 136 devices, covering: - **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers - **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit) @@ -26,7 +26,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.t # Quick mode — 6 representative devices, skip full matrix CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts -# Full device matrix only (135 devices) +# Full device matrix only (136 devices) npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts # Update visual baselines (delete old baselines, re-run) @@ -43,7 +43,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi | `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions | | `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence | | `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes | -| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (135 devices) | +| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (136 devices) | | `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints | | `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA | @@ -58,7 +58,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi | standard-tablet | 768–834px | ~8 | iPad Mini | | large-tablet | 835px+ | ~5 | iPad Pro 11" | -135 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 67 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, iPad Air M2, Surface Pro, etc.). +136 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 68 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.). ### How Devices Are Differentiated @@ -98,7 +98,7 @@ Test File ├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback ├─ helpers/assertions.ts → Layout, CSS, accessibility assertions ├─ helpers/visual.ts → pixelmatch screenshot comparison - └─ devices.ts → 135-device registry + └─ devices.ts → 136-device registry ``` ### Keyboard Simulation — 3-Layer Approach diff --git a/test/mobile/devices.ts b/test/mobile/devices.ts index 9202dcc5..f0c16bb7 100644 --- a/test/mobile/devices.ts +++ b/test/mobile/devices.ts @@ -1,6 +1,12 @@ import { devices as playwrightDevices } from 'playwright'; -export type DeviceCategory = 'small-phone' | 'standard-phone' | 'large-phone' | 'small-tablet' | 'standard-tablet' | 'large-tablet'; +export type DeviceCategory = + | 'small-phone' + | 'standard-phone' + | 'large-phone' + | 'small-tablet' + | 'standard-tablet' + | 'large-tablet'; export interface DeviceEntry { name: string; @@ -55,14 +61,7 @@ function fromPlaywright(name: string): DeviceEntry | null { } /** Create a custom DeviceEntry for devices not in Playwright. */ -function custom( - name: string, - width: number, - height: number, - dpr: number, - ua: string, - isIOS: boolean, -): DeviceEntry { +function custom(name: string, width: number, height: number, dpr: number, ua: string, isIOS: boolean): DeviceEntry { return { name, category: categoryFor(width), @@ -83,89 +82,89 @@ function custom( const PLAYWRIGHT_DEVICE_NAMES = [ // Small phones (<375px) - 'iPhone SE', // 320x568 - 'Galaxy S9+', // 320x658 - 'Nokia Lumia 520', // 320x533 - 'Galaxy S III', // 360x640 - 'Galaxy Note 3', // 360x640 - 'Galaxy Note II', // 360x640 - 'Galaxy S5', // 360x640 - 'Galaxy S8', // 360x740 - 'Galaxy S24', // 360x780 - 'BlackBerry Z30', // 360x640 + 'iPhone SE', // 320x568 + 'Galaxy S9+', // 320x658 + 'Nokia Lumia 520', // 320x533 + 'Galaxy S III', // 360x640 + 'Galaxy Note 3', // 360x640 + 'Galaxy Note II', // 360x640 + 'Galaxy S5', // 360x640 + 'Galaxy S8', // 360x740 + 'Galaxy S24', // 360x780 + 'BlackBerry Z30', // 360x640 'Microsoft Lumia 550', // 360x640 'Microsoft Lumia 950', // 360x640 - 'Nexus 5', // 360x640 - 'Moto G4', // 360x640 - 'Pixel 4', // 353x745 + 'Nexus 5', // 360x640 + 'Moto G4', // 360x640 + 'Pixel 4', // 353x745 // Standard phones (375-429px) - 'iPhone 6', // 375x667 - 'iPhone 7', // 375x667 - 'iPhone 8', // 375x667 + 'iPhone 6', // 375x667 + 'iPhone 7', // 375x667 + 'iPhone 8', // 375x667 'iPhone SE (3rd gen)', // 375x667 - 'iPhone X', // 375x812 - 'iPhone 11 Pro', // 375x635 - 'iPhone 12 Mini', // 375x629 - 'iPhone 13 Mini', // 375x629 - 'LG Optimus L70', // 384x640 - 'Nexus 4', // 384x640 - 'iPhone 12', // 390x664 - 'iPhone 12 Pro', // 390x664 - 'iPhone 13', // 390x664 - 'iPhone 13 Pro', // 390x664 - 'iPhone 14', // 390x664 - 'iPhone 14 Pro', // 393x660 - 'iPhone 15', // 393x659 - 'iPhone 15 Pro', // 393x659 - 'Pixel 3', // 393x786 - 'Pixel 5', // 393x727 - 'Pixel 2', // 411x731 - 'Pixel 2 XL', // 411x823 - 'Pixel 7', // 412x839 - 'Pixel 4a (5G)', // 412x765 - 'Nexus 5X', // 412x732 - 'Nexus 6', // 412x732 - 'Nexus 6P', // 412x732 - 'iPhone 6 Plus', // 414x736 - 'iPhone 7 Plus', // 414x736 - 'iPhone 8 Plus', // 414x736 - 'iPhone XR', // 414x896 - 'iPhone 11', // 414x715 - 'iPhone 11 Pro Max', // 414x715 - 'iPhone 12 Pro Max', // 428x746 - 'iPhone 13 Pro Max', // 428x746 - 'iPhone 14 Plus', // 428x746 + 'iPhone X', // 375x812 + 'iPhone 11 Pro', // 375x635 + 'iPhone 12 Mini', // 375x629 + 'iPhone 13 Mini', // 375x629 + 'LG Optimus L70', // 384x640 + 'Nexus 4', // 384x640 + 'iPhone 12', // 390x664 + 'iPhone 12 Pro', // 390x664 + 'iPhone 13', // 390x664 + 'iPhone 13 Pro', // 390x664 + 'iPhone 14', // 390x664 + 'iPhone 14 Pro', // 393x660 + 'iPhone 15', // 393x659 + 'iPhone 15 Pro', // 393x659 + 'Pixel 3', // 393x786 + 'Pixel 5', // 393x727 + 'Pixel 2', // 411x731 + 'Pixel 2 XL', // 411x823 + 'Pixel 7', // 412x839 + 'Pixel 4a (5G)', // 412x765 + 'Nexus 5X', // 412x732 + 'Nexus 6', // 412x732 + 'Nexus 6P', // 412x732 + 'iPhone 6 Plus', // 414x736 + 'iPhone 7 Plus', // 414x736 + 'iPhone 8 Plus', // 414x736 + 'iPhone XR', // 414x896 + 'iPhone 11', // 414x715 + 'iPhone 11 Pro Max', // 414x715 + 'iPhone 12 Pro Max', // 428x746 + 'iPhone 13 Pro Max', // 428x746 + 'iPhone 14 Plus', // 428x746 // Large phones (430-599px) - 'iPhone 14 Pro Max', // 430x740 - 'iPhone 15 Plus', // 430x739 - 'iPhone 15 Pro Max', // 430x739 - 'Galaxy A55', // 480x1040 - 'Nokia N9', // 480x854 + 'iPhone 14 Pro Max', // 430x740 + 'iPhone 15 Plus', // 430x739 + 'iPhone 15 Pro Max', // 430x739 + 'Galaxy A55', // 480x1040 + 'Nokia N9', // 480x854 // Small tablets (600-767px) 'Blackberry PlayBook', // 600x1024 - 'Nexus 7', // 600x960 - 'Galaxy Tab S9', // 640x1024 - 'iPad (gen 11)', // 656x944 - 'Galaxy Tab S4', // 712x1138 + 'Nexus 7', // 600x960 + 'Galaxy Tab S9', // 640x1024 + 'iPad (gen 11)', // 656x944 + 'Galaxy Tab S4', // 712x1138 // Standard tablets (768-834px) - 'iPad (gen 5)', // 768x1024 - 'iPad (gen 6)', // 768x1024 - 'iPad Mini', // 768x1024 - 'Kindle Fire HDX', // 800x1280 - 'Nexus 10', // 800x1280 - 'iPad (gen 7)', // 810x1080 + 'iPad (gen 5)', // 768x1024 + 'iPad (gen 6)', // 768x1024 + 'iPad Mini', // 768x1024 + 'Kindle Fire HDX', // 800x1280 + 'Nexus 10', // 800x1280 + 'iPad (gen 7)', // 810x1080 // Large tablets (834px+) - 'iPad Pro 11', // 834x1194 + 'iPad Pro 11', // 834x1194 ]; -const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES - .map(n => fromPlaywright(n)) - .filter((d): d is DeviceEntry => d !== null); +const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES.map((n) => fromPlaywright(n)).filter( + (d): d is DeviceEntry => d !== null +); // --------------------------------------------------------------------------- // Custom devices — newer models and those missing from Playwright @@ -185,84 +184,101 @@ const ANDROID_TABLET_UA = (androidVer: string, model: string) => const customEntries: DeviceEntry[] = [ // ── Small phones (<375px) ────────────────────────────────────────────── - custom('iPhone 5', 320, 568, 2, IOS_MOBILE_UA('10_3_4'), true), - custom('iPhone 5s', 320, 568, 2, IOS_MOBILE_UA('12_5_7'), true), - custom('iPhone 5c', 320, 568, 2, IOS_MOBILE_UA('10_3_3'), true), - custom('iPod Touch (7th gen)',320, 568, 2, IOS_MOBILE_UA('15_8'), true), - custom('Galaxy Y', 240, 320, 1, ANDROID_MOBILE_UA('2.3.6', 'GT-S5360'), false), - custom('Galaxy Ace', 320, 480, 1, ANDROID_MOBILE_UA('2.3.7', 'GT-S5830'), false), - custom('Pixel 4a', 353, 745, 2.75, ANDROID_MOBILE_UA('12', 'Pixel 4a'), false), + custom('iPhone 5', 320, 568, 2, IOS_MOBILE_UA('10_3_4'), true), + custom('iPhone 5s', 320, 568, 2, IOS_MOBILE_UA('12_5_7'), true), + custom('iPhone 5c', 320, 568, 2, IOS_MOBILE_UA('10_3_3'), true), + custom('iPod Touch (7th gen)', 320, 568, 2, IOS_MOBILE_UA('15_8'), true), + custom('Galaxy Y', 240, 320, 1, ANDROID_MOBILE_UA('2.3.6', 'GT-S5360'), false), + custom('Galaxy Ace', 320, 480, 1, ANDROID_MOBILE_UA('2.3.7', 'GT-S5830'), false), + custom('Pixel 4a', 353, 745, 2.75, ANDROID_MOBILE_UA('12', 'Pixel 4a'), false), // ── Standard phones (375-429px) ──────────────────────────────────────── - custom('iPhone 16', 393, 659, 3, IOS_MOBILE_UA('18_0'), true), - custom('iPhone 16 Pro', 402, 674, 3, IOS_MOBILE_UA('18_0'), true), - custom('Galaxy S20', 360, 800, 3, ANDROID_MOBILE_UA('12', 'SM-G980F'), false), - custom('Galaxy S20 FE', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G780F'), false), - custom('Galaxy S21', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G991B'), false), - custom('Galaxy S21 FE', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-G990B'), false), - custom('Galaxy S22', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S901B'), false), - custom('Galaxy S23', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S911B'), false), - custom('Galaxy S24 FE', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S721B'), false), - custom('Galaxy A54', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-A546B'), false), - custom('Galaxy A34', 360, 800, 2.625, ANDROID_MOBILE_UA('14', 'SM-A346B'), false), - custom('Galaxy A14', 384, 854, 1.5, ANDROID_MOBILE_UA('13', 'SM-A145F'), false), - custom('Galaxy Z Flip 5', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F731B'), false), - custom('Galaxy Z Flip 4', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F721B'), false), - custom('Pixel 6', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6'), false), - custom('Pixel 6a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6a'), false), - custom('Pixel 7a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7a'), false), - custom('Pixel 8', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8'), false), - custom('Pixel 8a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8a'), false), - custom('Pixel 9', 412, 923, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9'), false), - custom('OnePlus 12', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2581'), false), - custom('OnePlus Nord 3', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2491'), false), - custom('Xiaomi 14', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23127PN0CC'), false), - custom('Xiaomi Redmi Note 13',393, 873, 2.75, ANDROID_MOBILE_UA('14', '23106RN0DA'), false), - custom('Nothing Phone (2)', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'A065'), false), - custom('Sony Xperia 1 V', 411, 960, 2.625, ANDROID_MOBILE_UA('14', 'XQ-DQ72'), false), + custom('iPhone 16', 393, 659, 3, IOS_MOBILE_UA('18_0'), true), + custom('iPhone 16 Pro', 402, 674, 3, IOS_MOBILE_UA('18_0'), true), + custom('Galaxy S20', 360, 800, 3, ANDROID_MOBILE_UA('12', 'SM-G980F'), false), + custom('Galaxy S20 FE', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G780F'), false), + custom('Galaxy S21', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G991B'), false), + custom('Galaxy S21 FE', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-G990B'), false), + custom('Galaxy S22', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S901B'), false), + custom('Galaxy S23', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S911B'), false), + custom('Galaxy S24 FE', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S721B'), false), + custom('Galaxy A54', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-A546B'), false), + custom('Galaxy A34', 360, 800, 2.625, ANDROID_MOBILE_UA('14', 'SM-A346B'), false), + custom('Galaxy A14', 384, 854, 1.5, ANDROID_MOBILE_UA('13', 'SM-A145F'), false), + custom('Galaxy Z Flip 5', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F731B'), false), + custom('Galaxy Z Flip 4', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F721B'), false), + custom('Pixel 6', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6'), false), + custom('Pixel 6a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6a'), false), + custom('Pixel 7a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7a'), false), + custom('Pixel 8', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8'), false), + custom('Pixel 8a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8a'), false), + custom('Pixel 9', 412, 923, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9'), false), + custom('OnePlus 12', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2581'), false), + custom('OnePlus Nord 3', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2491'), false), + custom('Xiaomi 14', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23127PN0CC'), false), + custom('Xiaomi Redmi Note 13', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23106RN0DA'), false), + custom('Nothing Phone (2)', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'A065'), false), + custom('Sony Xperia 1 V', 411, 960, 2.625, ANDROID_MOBILE_UA('14', 'XQ-DQ72'), false), // ── Large phones (430-599px) ─────────────────────────────────────────── - custom('iPhone 16 Plus', 430, 739, 3, IOS_MOBILE_UA('18_0'), true), - custom('iPhone 16 Pro Max', 440, 756, 3, IOS_MOBILE_UA('18_0'), true), - custom('Galaxy S20 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G988B'), false), - custom('Galaxy S21 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G998B'), false), - custom('Galaxy S22 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S908B'), false), - custom('Galaxy S23 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S918B'), false), - custom('Galaxy S24 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S928B'), false), - custom('Galaxy Z Fold 5', 460, 1016, 2.5, ANDROID_MOBILE_UA('14', 'SM-F946B'), false), - custom('Pixel 6 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6 Pro'), false), - custom('Pixel 7 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7 Pro'), false), - custom('Pixel 8 Pro', 448, 998, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8 Pro'), false), - custom('Pixel 9 Pro XL', 448, 998, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9 Pro XL'), false), - custom('OnePlus 12 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'CPH2583'), false), + custom('iPhone 16 Plus', 430, 739, 3, IOS_MOBILE_UA('18_0'), true), + custom('iPhone 16 Pro Max', 440, 756, 3, IOS_MOBILE_UA('18_0'), true), + custom('Galaxy S20 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G988B'), false), + custom('Galaxy S21 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G998B'), false), + custom('Galaxy S22 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S908B'), false), + custom('Galaxy S23 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S918B'), false), + custom('Galaxy S24 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S928B'), false), + custom('Galaxy Z Fold 5', 460, 1016, 2.5, ANDROID_MOBILE_UA('14', 'SM-F946B'), false), + custom('Pixel 6 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6 Pro'), false), + custom('Pixel 7 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7 Pro'), false), + custom('Pixel 8 Pro', 448, 998, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8 Pro'), false), + custom('Pixel 9 Pro XL', 448, 998, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9 Pro XL'), false), + custom('OnePlus 12 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'CPH2583'), false), // ── Small tablets (600-767px) ────────────────────────────────────────── - custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false), - custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false), - custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false), - custom('Kindle Fire HD 8', 600, 1024, 1.5, 'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36', false), - custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false), - custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false), + custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false), + custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false), + custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false), + custom( + 'Kindle Fire HD 8', + 600, + 1024, + 1.5, + 'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36', + false + ), + custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false), + custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false), // ── Standard tablets (768-834px) ─────────────────────────────────────── - custom('iPad Air (5th gen)', 820, 1180, 2, IPAD_UA('16_0'), true), - custom('iPad (9th gen)', 810, 1080, 2, IPAD_UA('16_0'), true), - custom('iPad (10th gen)', 820, 1180, 2, IPAD_UA('16_0'), true), - custom('iPad Mini (6th gen)', 768, 1024, 2, IPAD_UA('16_0'), true), - custom('Galaxy Tab S7', 800, 1280, 2, ANDROID_TABLET_UA('13', 'SM-T870'), false), - custom('Galaxy Tab S8', 800, 1280, 2, ANDROID_TABLET_UA('14', 'SM-X700'), false), + custom('iPad Air (5th gen)', 820, 1180, 2, IPAD_UA('16_0'), true), + custom('iPad (9th gen)', 810, 1080, 2, IPAD_UA('16_0'), true), + custom('iPad (10th gen)', 820, 1180, 2, IPAD_UA('16_0'), true), + custom('iPad Mini (6th gen)', 768, 1024, 2, IPAD_UA('16_0'), true), + custom('Galaxy Tab S7', 800, 1280, 2, ANDROID_TABLET_UA('13', 'SM-T870'), false), + custom('Galaxy Tab S8', 800, 1280, 2, ANDROID_TABLET_UA('14', 'SM-X700'), false), // ── Large tablets (834px+) ────────────────────────────────────────────── custom('iPad Pro 12.9 (6th gen)', 1024, 1366, 2, IPAD_UA('16_0'), true), - custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true), - custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true), - custom('Surface Pro 7', 912, 1368, 2, - 'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0', false), - custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false), - custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false), - custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false), - custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false), - custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false), + custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true), + custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true), + custom( + 'Surface Pro 7', + 912, + 1368, + 2, + 'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0', + false + ), + custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false), + custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false), + custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false), + custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false), + custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false), + // Find N5 inner display is 2248x2480 physical pixels. At DPR 2 its full- + // resolution CSS viewport crosses Codeman's desktop breakpoint while the + // browser remains a mobile/touch device. + custom('OPPO Find N5 (unfolded)', 1124, 1240, 2, ANDROID_MOBILE_UA('15', 'CPH2671'), false), ]; // --------------------------------------------------------------------------- @@ -276,29 +292,29 @@ export const DEVICE_REGISTRY: DeviceEntry[] = [...playwrightEntries, ...customEn // Per-category exports // --------------------------------------------------------------------------- -export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-phone'); -export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-phone'); -export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-phone'); -export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-tablet'); -export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-tablet'); -export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-tablet'); +export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-phone'); +export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-phone'); +export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-phone'); +export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-tablet'); +export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-tablet'); +export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-tablet'); // --------------------------------------------------------------------------- // Platform exports // --------------------------------------------------------------------------- -export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.isIOS); -export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => !d.isIOS); +export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.isIOS); +export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => !d.isIOS); // --------------------------------------------------------------------------- // Representative devices — one per category for quick smoke tests // --------------------------------------------------------------------------- export const REPRESENTATIVE_DEVICES: Record = { - 'small-phone': SMALL_PHONES.find(d => d.name === 'iPhone SE')!, - 'standard-phone': STANDARD_PHONES.find(d => d.name === 'iPhone 14 Pro')!, - 'large-phone': LARGE_PHONES.find(d => d.name === 'iPhone 15 Pro Max')!, - 'small-tablet': SMALL_TABLETS.find(d => d.name === 'Nexus 7')!, - 'standard-tablet': STANDARD_TABLETS.find(d => d.name === 'iPad Mini')!, - 'large-tablet': LARGE_TABLETS.find(d => d.name === 'iPad Pro 11')!, + 'small-phone': SMALL_PHONES.find((d) => d.name === 'iPhone SE')!, + 'standard-phone': STANDARD_PHONES.find((d) => d.name === 'iPhone 14 Pro')!, + 'large-phone': LARGE_PHONES.find((d) => d.name === 'iPhone 15 Pro Max')!, + 'small-tablet': SMALL_TABLETS.find((d) => d.name === 'Nexus 7')!, + 'standard-tablet': STANDARD_TABLETS.find((d) => d.name === 'iPad Mini')!, + 'large-tablet': LARGE_TABLETS.find((d) => d.name === 'iPad Pro 11')!, }; diff --git a/test/mobile/settings.test.ts b/test/mobile/settings.test.ts index c18ecf87..5d6282b2 100644 --- a/test/mobile/settings.test.ts +++ b/test/mobile/settings.test.ts @@ -5,10 +5,8 @@ import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './ import { createTestServer, stopTestServer } from './helpers/server.js'; import { createDevicePage, closeAllBrowsers } from './helpers/browser.js'; import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js'; -import { - assertVisible, assertHidden, getCSSProperty, getCSSNumericValue, -} from './helpers/assertions.js'; -import { REPRESENTATIVE_DEVICES } from './devices.js'; +import { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from './helpers/assertions.js'; +import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js'; import type { WebServer } from '../src/web/server.js'; const PORT = PORTS.SETTINGS; @@ -94,9 +92,7 @@ describe('Settings Modal', () => { if (gearBox && toolbarBox) { // Gear button should be within toolbar's vertical range expect(gearBox.y).toBeGreaterThanOrEqual(toolbarBox.y - 5); - expect(gearBox.y + gearBox.height).toBeLessThanOrEqual( - toolbarBox.y + toolbarBox.height + 5, - ); + expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(toolbarBox.y + toolbarBox.height + 5); } }); }); @@ -304,11 +300,14 @@ describe('Settings Modal', () => { try { // Store a test setting await page.evaluate((key) => { - localStorage.setItem(key, JSON.stringify({ - showFontControls: true, - showMonitor: true, - subagentTrackingEnabled: true, - })); + localStorage.setItem( + key, + JSON.stringify({ + showFontControls: true, + showMonitor: true, + subagentTrackingEnabled: true, + }) + ); }, STORAGE_KEYS.SETTINGS_MOBILE); // Reload page @@ -335,20 +334,32 @@ describe('Settings Modal', () => { try { // Store both mobile and desktop settings - await page.evaluate(({ mobileKey, desktopKey, notifKey }) => { - localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false })); - localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true })); - localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true })); - }, { - mobileKey: STORAGE_KEYS.SETTINGS_MOBILE, - desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP, - notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE, - }); + await page.evaluate( + ({ mobileKey, desktopKey, notifKey }) => { + localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false })); + localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true })); + localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true })); + }, + { + mobileKey: STORAGE_KEYS.SETTINGS_MOBILE, + desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP, + notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE, + } + ); // Verify they are independent - const mobile = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_MOBILE); - const desktop = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_DESKTOP); - const notif = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE); + const mobile = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) || '{}'), + STORAGE_KEYS.SETTINGS_MOBILE + ); + const desktop = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) || '{}'), + STORAGE_KEYS.SETTINGS_DESKTOP + ); + const notif = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) || '{}'), + STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE + ); expect(mobile.showFontControls).toBe(false); expect(desktop.showFontControls).toBe(true); @@ -390,5 +401,53 @@ describe('Settings Modal', () => { await context.close(); } }); + + it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => { + const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!; + const { page, context } = await createDevicePage(device, BASE_URL, 'chromium'); + + try { + // Seed the preferences while folded, exactly as a phone user does. + await page.setViewportSize({ width: 412, height: 915 }); + await page.evaluate((key) => { + localStorage.setItem( + key, + JSON.stringify({ + showResponseViewer: true, + extendedKeyboardBar: true, + }) + ); + }, STORAGE_KEYS.SETTINGS_MOBILE); + await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED }); + await page.waitForTimeout(WAIT.SSE_CONNECT); + + // Unfolding can reload Android WebView. The viewport now uses desktop + // layout, but the physical device and its preferences have not changed. + await page.setViewportSize(device.viewport); + await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED }); + await page.waitForTimeout(WAIT.SSE_CONNECT); + + const state = await page.evaluate(() => ({ + deviceType: (window as any).MobileDetection.getDeviceType(), + handheld: (window as any).MobileDetection.isHandheldDevice(), + storageKey: (window as any).app.getSettingsStorageKey(), + responseViewerVisible: !document + .querySelector('.btn-response-viewer-header') + ?.classList.contains('btn-response-viewer-header--hidden'), + keyboardExtended: Boolean(document.querySelector('.keyboard-accessory-bar [data-action="arrow-left"]')), + })); + + expect(state.deviceType).toBe('desktop'); + expect(state.handheld).toBe(true); + expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE); + expect(state.responseViewerVisible).toBe(true); + expect(state.keyboardExtended).toBe(true); + + await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); + await assertVisible(page, '.keyboard-accessory-bar'); + } finally { + await context.close(); + } + }); }); }); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index c778300a..0c5b25af 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -73,6 +73,101 @@ describe('run mode UI', () => { }); }); +describe('Run launch synchronization', () => { + it('coalesces overlapping Run activations and disables the button while the request is active', async () => { + const runBtn = { + disabled: false, + setAttribute: vi.fn(), + removeAttribute: vi.fn(), + }; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => (id === 'runBtn' ? runBtn : null) }, + console, + }); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app._runMinLockMs = 0; + let finishRun!: () => void; + app.runClaude = vi.fn( + () => + new Promise((resolveRun) => { + finishRun = resolveRun; + }) + ); + + const first = app.run(); + const duplicate = app.run(); + + expect(app.runClaude).toHaveBeenCalledTimes(1); + expect(runBtn.disabled).toBe(true); + expect(runBtn.setAttribute).toHaveBeenCalledWith('aria-busy', 'true'); + + finishRun(); + await Promise.all([first, duplicate]); + + expect(runBtn.disabled).toBe(false); + expect(runBtn.removeAttribute).toHaveBeenCalledWith('aria-busy'); + }); + + it('renders a POST response session immediately without waiting for SSE', async () => { + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + fetch: vi.fn(), + console, + }); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app.sessions = new Map(); + app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session)); + app._renderSessionTabsImmediate = vi.fn(); + const snapshot = { id: 'sess-new', name: 'w1-case', workingDir: '/tmp/case' }; + + await app._ensureCreatedSessionVisible(snapshot.id, snapshot); + + expect(context.fetch).not.toHaveBeenCalled(); + expect(app.sessions.get(snapshot.id)).toEqual(snapshot); + expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1); + }); + + it('loads the new session when a quick-start response wins the race with SSE', async () => { + const snapshot = { id: 'sess-race', name: 'w1-remote', workingDir: '/remote/work' }; + const fetchMock = vi.fn(async () => ({ + json: async () => ({ success: true, data: snapshot }), + })); + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + fetch: fetchMock, + console, + }); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app.sessions = new Map(); + app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session)); + app._renderSessionTabsImmediate = vi.fn(); + + await app._ensureCreatedSessionVisible(snapshot.id); + + expect(fetchMock).toHaveBeenCalledWith('/api/sessions/sess-race'); + expect(app.sessions.get(snapshot.id)).toEqual(snapshot); + expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1); + }); +}); + describe('Codex quick start settings', () => { it('renders Codex CLI settings in a dedicated app settings tab', () => { const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8'); @@ -113,6 +208,8 @@ describe('Codex quick start settings', () => { requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined }); if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) }; if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) }; + if (url === '/api/sessions/sess-1') + return { json: async () => ({ success: true, data: { id: 'sess-1', name: 'w1-codex-case' } }) }; throw new Error(`unexpected fetch: ${url}`); }, console, @@ -128,6 +225,9 @@ describe('Codex quick start settings', () => { }); app.getCaseSettings = () => ({}); app.buildEnvOverrides = () => ({}); + app.sessions = new Map(); + app._onSessionCreated = (session: any) => app.sessions.set(session.id, session); + app._renderSessionTabsImmediate = vi.fn(); const selected: string[] = []; app.selectSession = async (id: string) => { selected.push(id); @@ -424,6 +524,8 @@ describe('Gemini quick start', () => { if (url === '/api/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) }; if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) }; + if (url === '/api/sessions/sess-gm') + return { json: async () => ({ success: true, data: { id: 'sess-gm', name: 'w1-gemini-case' } }) }; throw new Error(`unexpected fetch: ${url}`); }, console, @@ -437,6 +539,9 @@ describe('Gemini quick start', () => { app.loadAppSettingsFromStorage = () => ({}); app.getCaseSettings = () => ({}); app.buildEnvOverrides = () => ({}); + app.sessions = new Map(); + app._onSessionCreated = (session: any) => app.sessions.set(session.id, session); + app._renderSessionTabsImmediate = vi.fn(); const selected: string[] = []; app.selectSession = async (id: string) => { selected.push(id); diff --git a/test/working-directory-schema.test.ts b/test/working-directory-schema.test.ts new file mode 100644 index 00000000..52080c56 --- /dev/null +++ b/test/working-directory-schema.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { CreateSessionSchema, QuickRunSchema, ScheduledRunSchema } from '../src/web/schemas.js'; + +describe('working directory schemas', () => { + const unicodeWorkingDir = '/mnt/d/AI/中文项目'; + + it('accepts Unicode paths for session creation and run requests', () => { + expect(CreateSessionSchema.safeParse({ workingDir: unicodeWorkingDir, mode: 'codex' }).success).toBe(true); + expect(QuickRunSchema.safeParse({ workingDir: unicodeWorkingDir, prompt: 'test' }).success).toBe(true); + expect( + ScheduledRunSchema.safeParse({ workingDir: unicodeWorkingDir, prompt: 'test', durationMinutes: 10 }).success + ).toBe(true); + }); + + it('continues to reject shell metacharacters in Unicode paths', () => { + const unsafeWorkingDir = `${unicodeWorkingDir};rm -rf /`; + + expect(CreateSessionSchema.safeParse({ workingDir: unsafeWorkingDir, mode: 'codex' }).success).toBe(false); + expect(QuickRunSchema.safeParse({ workingDir: unsafeWorkingDir, prompt: 'test' }).success).toBe(false); + expect( + ScheduledRunSchema.safeParse({ workingDir: unsafeWorkingDir, prompt: 'test', durationMinutes: 10 }).success + ).toBe(false); + }); +});