Merge master into feat/custom-name-i18n; keep windowTitle stable on solo renders

Resolves the CLAUDE.md paragraph conflict with #162, skips the
windowTitle recompute for solo-session renders so a detached window
cannot reset the push-notification hostTitle prefix to the default,
and prettier-formats test/mobile/devices.ts (came in unformatted via
the #162 merge; CI format:check only covers src/**).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-23 09:17:04 +02:00
18 changed files with 761 additions and 245 deletions
+3
View File
@@ -2,6 +2,9 @@
.agents/
skills-lock.json
# Written by install.sh into end-user clones when setup finishes
.install-complete
# Dependencies
node_modules/
+13
View File
@@ -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
+8 -5
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -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.
<details>
<summary><strong>Run as a background service</strong></summary>
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
+8 -2
View File
@@ -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 # 命名登录 + 按用户隔离的案例
<details>
<summary><strong>作为后台服务运行</strong></summary>
安装器结尾的菜单(选项 2)可以帮你完成这一步,并在宣告成功前校验服务确实已启动。如需手动配置:
**Linux(systemd):**
```bash
+218 -22
View File
@@ -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 <action>: 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
# 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
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 ""
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
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
+2 -2
View File
@@ -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": [
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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.
+29
View File
@@ -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 (
+52 -4
View File
@@ -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() {
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');
}
try {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
return this.runOpenCode();
return await this.runOpenCode();
}
if (mode === 'codex') {
return this.runCodex();
return await this.runCodex();
}
if (mode === 'gemini') {
return this.runGemini();
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');
}
}
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);
+10 -6
View File
@@ -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();
+4
View File
@@ -1226,7 +1226,11 @@ export class WebServer extends EventEmitter {
const configuredDisplayName =
typeof persistedSettings.displayName === 'string' ? persistedSettings.displayName.trim() : '';
const displayName = configuredDisplayName || 'Codeman';
// 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(
'<title>Codeman</title>',
`<title>${escapeHtmlText(this.windowTitle)}</title>`
+6 -6
View File
@@ -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
+47 -31
View File
@@ -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),
@@ -163,9 +162,9 @@ const PLAYWRIGHT_DEVICE_NAMES = [
'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
@@ -188,7 +187,7 @@ const customEntries: DeviceEntry[] = [
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('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),
@@ -217,7 +216,7 @@ const customEntries: DeviceEntry[] = [
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('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),
@@ -240,7 +239,14 @@ const customEntries: DeviceEntry[] = [
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(
'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),
@@ -256,13 +262,23 @@ const customEntries: DeviceEntry[] = [
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(
'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<DeviceCategory, DeviceEntry> = {
'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')!,
};
+74 -15
View File
@@ -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({
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 }) => {
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();
}
});
});
});
+105
View File
@@ -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<void>((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);
+24
View File
@@ -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);
});
});